@taurgis/bonsai 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -2
- package/agents/agents/official-docs-researcher.agent.md +10 -1
- package/agents/agents/salesforce-docs-researcher.agent.md +126 -0
- package/agents/hooks/README.md +62 -0
- package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +22 -0
- package/agents/hooks/claude-code/settings.json +16 -0
- package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +18 -0
- package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +22 -0
- package/agents/hooks/codex/hooks.json +28 -0
- package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +21 -0
- package/agents/hooks/cursor/hooks.json +12 -0
- package/agents/hooks/github-copilot/bonsai-web-fetch.json +23 -0
- package/agents/hooks/github-copilot/bonsai-web-fetch.sh +19 -0
- package/agents/instructions/salesforce-research.instructions.md +59 -0
- package/agents/instructions/web-research.instructions.md +5 -2
- package/agents/skills/web-research/SKILL.md +8 -3
- package/bin/cli.mjs +35 -8
- package/dist/base-command.d.ts +23 -0
- package/dist/base-command.js +67 -8
- package/dist/commands/config/base.d.ts +2 -0
- package/dist/commands/config/base.js +15 -1
- package/dist/commands/config/get.d.ts +1 -1
- package/dist/commands/config/get.js +6 -7
- package/dist/commands/config/index.d.ts +12 -0
- package/dist/commands/config/index.js +34 -0
- package/dist/commands/config/list.js +4 -4
- package/dist/commands/config/set.js +20 -14
- package/dist/commands/config/unset.d.ts +1 -1
- package/dist/commands/config/unset.js +11 -8
- package/dist/commands/fetch.d.ts +5 -0
- package/dist/commands/fetch.js +122 -47
- package/dist/commands/import.d.ts +4 -0
- package/dist/commands/import.js +99 -37
- package/dist/commands/inspect.d.ts +4 -0
- package/dist/commands/inspect.js +24 -19
- package/dist/commands/list.d.ts +2 -2
- package/dist/commands/list.js +39 -16
- package/dist/commands/prune.d.ts +1 -1
- package/dist/commands/prune.js +50 -17
- package/dist/commands/search.d.ts +4 -0
- package/dist/commands/search.js +102 -41
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.js +58 -31
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +2 -0
- package/dist/hooks/command-not-found/suggest.d.ts +9 -0
- package/dist/hooks/command-not-found/suggest.js +122 -0
- package/dist/lib/argv.d.ts +10 -0
- package/dist/lib/argv.js +53 -0
- package/dist/lib/config/index.d.ts +1 -1
- package/dist/lib/config/index.js +1 -1
- package/dist/lib/config/resolve.d.ts +7 -0
- package/dist/lib/config/resolve.js +23 -0
- package/dist/lib/envelope.d.ts +36 -0
- package/dist/lib/envelope.js +76 -0
- package/dist/lib/json-meta.d.ts +10 -0
- package/dist/lib/json-meta.js +98 -0
- package/dist/lib/research/browser.d.ts +12 -0
- package/dist/lib/research/browser.js +40 -3
- package/dist/lib/research/docs/capabilities.d.ts +1 -1
- package/dist/lib/research/docs/discovery-index.d.ts +14 -0
- package/dist/lib/research/docs/discovery-index.js +112 -0
- package/dist/lib/research/docs/machine-readable.d.ts +2 -2
- package/dist/lib/research/docs/machine-readable.js +3 -7
- package/dist/lib/research/docs/remote-search-runner.js +61 -1
- package/dist/lib/research/docs/sections.d.ts +1 -0
- package/dist/lib/research/docs/sections.js +19 -1
- package/dist/lib/research/freshness.d.ts +9 -1
- package/dist/lib/research/freshness.js +24 -2
- package/dist/lib/research/keywords.d.ts +10 -3
- package/dist/lib/research/keywords.js +32 -5
- package/dist/lib/research/revalidate.js +3 -1
- package/dist/lib/research/schema.d.ts +5 -2
- package/dist/lib/research/schema.js +12 -1
- package/dist/lib/research/secret-scan.js +4 -2
- package/dist/lib/research/url.js +5 -2
- package/dist/lib/text.d.ts +35 -0
- package/dist/lib/text.js +65 -0
- package/dist/sites/algolia.d.ts +9 -0
- package/dist/sites/algolia.js +26 -0
- package/dist/sites/auto.d.ts +8 -0
- package/dist/sites/auto.js +30 -0
- package/dist/sites/index.js +17 -1
- package/dist/sites/nextjs/index.d.ts +2 -0
- package/dist/sites/nextjs/index.js +52 -0
- package/dist/sites/react/index.d.ts +2 -0
- package/dist/sites/react/index.js +17 -0
- package/dist/sites/tailwind/index.d.ts +2 -0
- package/dist/sites/tailwind/index.js +17 -0
- package/dist/sites/vue/index.d.ts +2 -0
- package/dist/sites/vue/index.js +17 -0
- package/oclif.manifest.json +154 -81
- package/package.json +24 -4
package/dist/commands/status.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../base-command.js';
|
|
3
|
+
import { formatErrorForJson } from '../lib/envelope.js';
|
|
3
4
|
import { getArtifactPath } from '../lib/research/storage.js';
|
|
4
|
-
import { evaluateFreshness, checkMaxAgeExpired } from '../lib/research/freshness.js';
|
|
5
|
-
import { resolveResearchTarget } from '../lib/research/resolve-target.js';
|
|
5
|
+
import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../lib/research/freshness.js';
|
|
6
6
|
function resolveFreshness(cached, currentTime, ttl, maxAge) {
|
|
7
7
|
const isMaxAgeExpired = maxAge ? checkMaxAgeExpired(cached, currentTime, maxAge) : false;
|
|
8
8
|
return isMaxAgeExpired
|
|
@@ -11,7 +11,7 @@ function resolveFreshness(cached, currentTime, ttl, maxAge) {
|
|
|
11
11
|
}
|
|
12
12
|
function describeCacheStatus(cached, currentTime, ttl, maxAge) {
|
|
13
13
|
if (!cached) {
|
|
14
|
-
return { status: 'miss', freshness: '
|
|
14
|
+
return { status: 'miss', freshness: 'none', action: 'would_fetch' };
|
|
15
15
|
}
|
|
16
16
|
const freshness = resolveFreshness(cached, currentTime, ttl, maxAge);
|
|
17
17
|
return freshness === 'fresh'
|
|
@@ -22,65 +22,92 @@ export default class ResearchStatus extends BaseCommand {
|
|
|
22
22
|
static id = 'status';
|
|
23
23
|
static summary = 'Check the cache status of a URL without fetching or writing.';
|
|
24
24
|
static description = 'Reports whether the URL is cached, its freshness state, and what action (fetch, revalidate, or cached return) the root fetch command would take.';
|
|
25
|
+
static examples = [
|
|
26
|
+
{
|
|
27
|
+
description: 'check if a URL is already in the cache',
|
|
28
|
+
command: '<%= config.bin %> <%= command.id %> https://example.com/docs',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
description: 'check status against a specific freshness tier and TTL',
|
|
32
|
+
command: '<%= config.bin %> <%= command.id %> https://example.com/docs --tier volatile --ttl 2h',
|
|
33
|
+
},
|
|
34
|
+
];
|
|
25
35
|
static args = {
|
|
26
36
|
url: Args.string({
|
|
27
37
|
required: true,
|
|
28
|
-
description: '
|
|
38
|
+
description: 'the URL to check',
|
|
29
39
|
}),
|
|
30
40
|
};
|
|
31
41
|
static flags = {
|
|
32
42
|
tier: Flags.option({
|
|
33
|
-
description: '
|
|
43
|
+
description: 'freshness tier policy to evaluate against',
|
|
34
44
|
options: ['stable', 'standard', 'volatile'],
|
|
35
45
|
default: 'standard',
|
|
36
46
|
})(),
|
|
37
47
|
ttl: Flags.string({
|
|
38
48
|
char: 'l',
|
|
39
|
-
description: '
|
|
49
|
+
description: 'custom TTL duration to evaluate against (e.g. "2h", "7d")',
|
|
40
50
|
}),
|
|
41
51
|
'max-age': Flags.string({
|
|
42
|
-
description: '
|
|
52
|
+
description: 'maximum age of cache to accept (e.g. "1d", "30d")',
|
|
43
53
|
}),
|
|
44
54
|
};
|
|
45
55
|
static stdoutIsPrimaryData = true;
|
|
56
|
+
/** Enrich cache-miss envelopes with the same CACHE_MISS code and suggestions as inspect. */
|
|
57
|
+
toSuccessJson(data) {
|
|
58
|
+
const envelope = super.toSuccessJson(data);
|
|
59
|
+
const d = data;
|
|
60
|
+
if (d?.status !== 'miss')
|
|
61
|
+
return envelope;
|
|
62
|
+
const normalizedUrl = d.normalizedUrl ?? '';
|
|
63
|
+
const suggestions = [`Fetch and cache it first: ${this.config.bin} ${normalizedUrl}`];
|
|
64
|
+
const stderr = formatErrorForJson({
|
|
65
|
+
message: `Cache miss for ${normalizedUrl}`,
|
|
66
|
+
code: 'CACHE_MISS',
|
|
67
|
+
suggestions,
|
|
68
|
+
});
|
|
69
|
+
return { ...envelope, ok: false, exitCode: 1, stderr, code: 'CACHE_MISS', suggestions };
|
|
70
|
+
}
|
|
46
71
|
async run() {
|
|
47
72
|
const { url } = this.args;
|
|
48
73
|
const { ttl, tier, 'max-age': maxAge } = this.flags;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
dataDir: this.config.dataDir,
|
|
55
|
-
url,
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
catch (err) {
|
|
59
|
-
this.error(`Invalid URL: ${err.message}`, { exit: 2 });
|
|
74
|
+
// Validate the duration flags up front so a malformed value reports the exact flag that is
|
|
75
|
+
// wrong, rather than later surfacing as a misattributed parse failure.
|
|
76
|
+
for (const msg of [durationFlagError('--ttl', ttl), durationFlagError('--max-age', maxAge)]) {
|
|
77
|
+
if (msg)
|
|
78
|
+
this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
|
|
60
79
|
}
|
|
80
|
+
const target = this.resolveResearchTargetOrFail(url);
|
|
61
81
|
const { cacheKey, located, normalizedUrl, roots } = target;
|
|
62
82
|
const cached = located?.artifact ?? null;
|
|
63
83
|
const currentTime = new Date();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
result = describeCacheStatus(cached, currentTime, ttl, maxAge);
|
|
67
|
-
}
|
|
68
|
-
catch (err) {
|
|
69
|
-
this.error(`Invalid max-age: ${err.message}`, { exit: 2 });
|
|
70
|
-
}
|
|
84
|
+
// Both duration flags were validated above, so freshness resolution cannot throw on a parse error.
|
|
85
|
+
const result = describeCacheStatus(cached, currentTime, ttl, maxAge);
|
|
71
86
|
// On a hit, report where it actually lives; on a miss, where a fetch would write it.
|
|
72
87
|
const artifactPath = located?.path ?? getArtifactPath(roots.writeRoot, cacheKey);
|
|
73
88
|
if (!this.jsonEnabled()) {
|
|
74
|
-
this.log(
|
|
75
|
-
this.log(
|
|
76
|
-
this.log(
|
|
77
|
-
this.log(
|
|
78
|
-
this.log(
|
|
79
|
-
this.log(
|
|
89
|
+
this.log(`${'URL:'.padEnd(25)} ${normalizedUrl}`);
|
|
90
|
+
this.log(`${'Cache Key:'.padEnd(25)} ${cacheKey}`);
|
|
91
|
+
this.log(`${'Cache Path:'.padEnd(25)} ${artifactPath}`);
|
|
92
|
+
this.log(`${'Status:'.padEnd(25)} ${result.status}`);
|
|
93
|
+
this.log(`${'Freshness:'.padEnd(25)} ${result.freshness}`);
|
|
94
|
+
this.log(`${'Action:'.padEnd(25)} ${result.action}`);
|
|
95
|
+
}
|
|
96
|
+
// Align with inspect's CACHE_MISS contract: a miss is actionable, not a usage error. Exit 1
|
|
97
|
+
// lets scripts branch on $? while JSON still returns the structured status payload. Unlike
|
|
98
|
+
// inspect, we do not throw here — status must always return structured data even on a miss.
|
|
99
|
+
if (result.status === 'miss') {
|
|
100
|
+
// Human mode: actionable hint on stderr. JSON mode: same info lives in the envelope (see
|
|
101
|
+
// toSuccessJson) so agents get code/suggestions without parsing warn() line wraps.
|
|
102
|
+
if (!this.jsonEnabled()) {
|
|
103
|
+
this.warn(`Cache miss — run: ${this.config.bin} ${normalizedUrl}`);
|
|
104
|
+
}
|
|
105
|
+
process.exitCode = 1;
|
|
80
106
|
}
|
|
81
107
|
return {
|
|
82
108
|
cacheKey,
|
|
83
109
|
cachePath: artifactPath,
|
|
110
|
+
normalizedUrl,
|
|
84
111
|
status: result.status,
|
|
85
112
|
freshness: result.freshness,
|
|
86
113
|
action: result.action,
|
package/dist/commands.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ConfigGet from './commands/config/get.js';
|
|
2
|
+
import ConfigIndex from './commands/config/index.js';
|
|
2
3
|
import ConfigList from './commands/config/list.js';
|
|
3
4
|
import ConfigSet from './commands/config/set.js';
|
|
4
5
|
import ConfigUnset from './commands/config/unset.js';
|
|
@@ -17,6 +18,7 @@ export declare const commands: {
|
|
|
17
18
|
prune: typeof PruneCommand;
|
|
18
19
|
search: typeof SearchCommand;
|
|
19
20
|
status: typeof StatusCommand;
|
|
21
|
+
config: typeof ConfigIndex;
|
|
20
22
|
'config:get': typeof ConfigGet;
|
|
21
23
|
'config:list': typeof ConfigList;
|
|
22
24
|
'config:set': typeof ConfigSet;
|
package/dist/commands.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ConfigGet from './commands/config/get.js';
|
|
2
|
+
import ConfigIndex from './commands/config/index.js';
|
|
2
3
|
import ConfigList from './commands/config/list.js';
|
|
3
4
|
import ConfigSet from './commands/config/set.js';
|
|
4
5
|
import ConfigUnset from './commands/config/unset.js';
|
|
@@ -17,6 +18,7 @@ export const commands = {
|
|
|
17
18
|
prune: PruneCommand,
|
|
18
19
|
search: SearchCommand,
|
|
19
20
|
status: StatusCommand,
|
|
21
|
+
config: ConfigIndex,
|
|
20
22
|
'config:get': ConfigGet,
|
|
21
23
|
'config:list': ConfigList,
|
|
22
24
|
'config:set': ConfigSet,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Hook } from '@oclif/core';
|
|
2
|
+
/**
|
|
3
|
+
* Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
|
|
4
|
+
* throwing here (via `this.error`) replaces that default with a more helpful message. A correction
|
|
5
|
+
* is offered only when the nearest command is a close match, and it is never auto-run — the user
|
|
6
|
+
* re-runs the corrected command themselves, so they learn the right syntax (clig.dev guidance).
|
|
7
|
+
*/
|
|
8
|
+
declare const hook: Hook<'command_not_found'>;
|
|
9
|
+
export default hook;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { toConfiguredId } from '@oclif/core';
|
|
2
|
+
import { closestMatch } from '../../lib/text.js';
|
|
3
|
+
import { buildEnvelope, formatErrorForJson } from '../../lib/envelope.js';
|
|
4
|
+
// Nearest command must be close enough to be useful. A fixed threshold of 3 is too generous for
|
|
5
|
+
// very short inputs (`wat` should not suggest `list`), while still reasonable for longer topic ids.
|
|
6
|
+
function maxSuggestionDistance(input) {
|
|
7
|
+
if (input.length <= 3)
|
|
8
|
+
return 1;
|
|
9
|
+
if (input.length <= 5)
|
|
10
|
+
return 2;
|
|
11
|
+
return 3;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The nearest visible command to a typo, plus how many leading segments of the attempted id name
|
|
15
|
+
* the command. With `topicSeparator: ' '`, oclif folds every leading token of an unknown command
|
|
16
|
+
* into one colon-delimited id, so `bonsai serch hello` arrives as `serch:hello`. Matching that whole
|
|
17
|
+
* string never finds a short command, so walk prefixes longest→shortest and suggest off the closest:
|
|
18
|
+
* `serch:hello` resolves to nothing, but its prefix `serch` resolves to `search`. The matched prefix
|
|
19
|
+
* length doubles as the command-segment count, so a folded-in positional arg is dropped from display.
|
|
20
|
+
*/
|
|
21
|
+
function findSuggestion(segments, visibleIds) {
|
|
22
|
+
for (let n = segments.length; n >= 1; n--) {
|
|
23
|
+
const attempted = segments.slice(0, n).join(':');
|
|
24
|
+
const suggestion = closestMatch(attempted, visibleIds, maxSuggestionDistance(attempted));
|
|
25
|
+
if (suggestion)
|
|
26
|
+
return { suggestion, commandSegments: n };
|
|
27
|
+
}
|
|
28
|
+
return { suggestion: null, commandSegments: 0 };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* How many leading segments name the command when no suggestion exists: the known-topic chain plus
|
|
32
|
+
* the first unknown segment. Keeps `config frobnicate` from rendering as the misleading bare `config`
|
|
33
|
+
* (a real topic) while still dropping a positional arg from `frobnicate banana`.
|
|
34
|
+
*/
|
|
35
|
+
function topicChainLength(segments, config) {
|
|
36
|
+
let i = 0;
|
|
37
|
+
while (i < segments.length - 1 && config.findTopic(segments.slice(0, i + 1).join(':')))
|
|
38
|
+
i++;
|
|
39
|
+
return i + 1;
|
|
40
|
+
}
|
|
41
|
+
// Dot-separated, non-empty labels: a real domain (`docs.nestjs.com`) or IPv4 (`192.168.1.1`). The
|
|
42
|
+
// URL parser also accepts a leading-dot filename (`.env`) or a relative path (`./x`) as a "host",
|
|
43
|
+
// whose suggestion would be a nonsense `https://.env`; requiring well-formed labels rejects those.
|
|
44
|
+
// Anchored with no overlapping quantifiers, so it is ReDoS-safe on the (already length-bounded) host.
|
|
45
|
+
const DOMAIN_OR_IPV4 = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
|
46
|
+
/**
|
|
47
|
+
* The input as-typed when it looks like a URL missing its scheme (`example.com`,
|
|
48
|
+
* `docs.nestjs.com/guide`), else null. `bonsai <url>` is the headline command, but bin/cli.mjs only
|
|
49
|
+
* routes args carrying a `://` scheme to the fetch shorthand — so a scheme-less URL falls through to
|
|
50
|
+
* `command_not_found`. A domain-shaped host separates a forgotten-scheme URL from an ordinary command
|
|
51
|
+
* typo: `statuss` has no dot and never matches, while `config:frobnicate` fails to parse (the second
|
|
52
|
+
* segment becomes a non-numeric port), so neither is misread as a URL.
|
|
53
|
+
*/
|
|
54
|
+
function bareUrlInput(id) {
|
|
55
|
+
if (id.includes('://'))
|
|
56
|
+
return null;
|
|
57
|
+
let hostname;
|
|
58
|
+
try {
|
|
59
|
+
hostname = new URL(`https://${id}`).hostname;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return DOMAIN_OR_IPV4.test(hostname) ? id : null;
|
|
65
|
+
}
|
|
66
|
+
function isJsonMode(argv) {
|
|
67
|
+
return argv?.includes('--json') ?? false;
|
|
68
|
+
}
|
|
69
|
+
function emitJsonError(command, message, code) {
|
|
70
|
+
const envelope = buildEnvelope({
|
|
71
|
+
command,
|
|
72
|
+
ok: false,
|
|
73
|
+
exitCode: 2,
|
|
74
|
+
stderr: formatErrorForJson({ message, code }),
|
|
75
|
+
data: null,
|
|
76
|
+
code,
|
|
77
|
+
});
|
|
78
|
+
process.exitCode = 2;
|
|
79
|
+
console.log(JSON.stringify(envelope, null, 2));
|
|
80
|
+
return envelope;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
|
|
84
|
+
* throwing here (via `this.error`) replaces that default with a more helpful message. A correction
|
|
85
|
+
* is offered only when the nearest command is a close match, and it is never auto-run — the user
|
|
86
|
+
* re-runs the corrected command themselves, so they learn the right syntax (clig.dev guidance).
|
|
87
|
+
*/
|
|
88
|
+
const hook = async function (opts) {
|
|
89
|
+
const hiddenIds = new Set(opts.config.commands.filter((c) => c.hidden).map((c) => c.id));
|
|
90
|
+
const visibleIds = opts.config.commandIDs.filter((id) => !hiddenIds.has(id));
|
|
91
|
+
// A scheme-less URL is the most common "not a command" mistake for this CLI, so steer the user to
|
|
92
|
+
// the `bonsai <url>` shorthand with a scheme before falling back to nearest-command matching (which
|
|
93
|
+
// never finds a command for a hostname). The correction is shown, never auto-run (clig.dev).
|
|
94
|
+
const bareUrl = bareUrlInput(opts.id);
|
|
95
|
+
if (bareUrl) {
|
|
96
|
+
const message = [
|
|
97
|
+
`${bareUrl} is not a ${opts.config.bin} command.`,
|
|
98
|
+
`Run ${opts.config.bin} help for a list of available commands.`,
|
|
99
|
+
`Did you mean ${opts.config.bin} https://${bareUrl}? URLs need an http:// or https:// scheme.`,
|
|
100
|
+
].join('\n');
|
|
101
|
+
if (isJsonMode(opts.argv))
|
|
102
|
+
return emitJsonError(bareUrl, message, 'MISSING_URL_SCHEME');
|
|
103
|
+
this.error(message, { exit: 2 });
|
|
104
|
+
}
|
|
105
|
+
const segments = opts.id.split(':');
|
|
106
|
+
const { suggestion, commandSegments } = findSuggestion(segments, visibleIds);
|
|
107
|
+
// Show only the segments that name the command, never the positional args oclif folded into the id
|
|
108
|
+
// (which would otherwise glue the arg on and turn `://` into ` //`).
|
|
109
|
+
const displaySegments = commandSegments || topicChainLength(segments, opts.config);
|
|
110
|
+
const attempted = toConfiguredId(segments.slice(0, displaySegments).join(':'), opts.config);
|
|
111
|
+
// Order so the most actionable line lands last, where the eye rests (clig.dev): the help pointer
|
|
112
|
+
// is the floor, and a concrete "Did you mean …?" correction — when we have one — goes below it.
|
|
113
|
+
const lines = [`${attempted} is not a ${opts.config.bin} command.`];
|
|
114
|
+
lines.push(`Run ${opts.config.bin} help for a list of available commands.`);
|
|
115
|
+
if (suggestion)
|
|
116
|
+
lines.push(`Did you mean ${toConfiguredId(suggestion, opts.config)}?`);
|
|
117
|
+
const message = lines.join('\n');
|
|
118
|
+
if (isJsonMode(opts.argv))
|
|
119
|
+
return emitJsonError(attempted, message, 'COMMAND_NOT_FOUND');
|
|
120
|
+
this.error(message, { exit: 2 });
|
|
121
|
+
};
|
|
122
|
+
export default hook;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface NormalizationResult {
|
|
2
|
+
/** The normalized argv array to set on process.argv. */
|
|
3
|
+
argv: string[];
|
|
4
|
+
/** Optional metadata indicating the shim should exit early with a JSON envelope. */
|
|
5
|
+
exitWithJson?: {
|
|
6
|
+
exitCode: number;
|
|
7
|
+
envelope: Record<string, unknown>;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export declare function normalizeArgv(rawArgv: string[]): NormalizationResult;
|
package/dist/lib/argv.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { buildEnvelope } from './envelope.js';
|
|
2
|
+
/**
|
|
3
|
+
* Normalizes process.argv (excluding Node executable and script path) so the
|
|
4
|
+
* oclif pipeline sees one consistent command structure.
|
|
5
|
+
*/
|
|
6
|
+
function missingUsageJsonExit() {
|
|
7
|
+
return {
|
|
8
|
+
exitCode: 2,
|
|
9
|
+
envelope: buildEnvelope({
|
|
10
|
+
command: 'bonsai',
|
|
11
|
+
ok: false,
|
|
12
|
+
exitCode: 2,
|
|
13
|
+
stderr: 'Missing URL or command. Run bonsai --help for usage.',
|
|
14
|
+
data: null,
|
|
15
|
+
}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function normalizeArgv(rawArgv) {
|
|
19
|
+
const onlyJsonFlags = rawArgv.length > 0 && rawArgv.every((arg) => arg === '--json');
|
|
20
|
+
if (onlyJsonFlags) {
|
|
21
|
+
return {
|
|
22
|
+
// bin/cli.mjs exits before applying argv when exitWithJson is set.
|
|
23
|
+
argv: ['--json'],
|
|
24
|
+
exitWithJson: missingUsageJsonExit(),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
// oclif's JSON flag is command-scoped, so `bonsai list --json` works but
|
|
28
|
+
// `bonsai --json list` is otherwise parsed as an unknown command named
|
|
29
|
+
// "--json". Collect every --json, append one copy after the command/URL, and
|
|
30
|
+
// dedupe repeats like `bonsai --json --json list`.
|
|
31
|
+
const jsonMode = rawArgv.includes('--json');
|
|
32
|
+
let tokens = rawArgv.filter((arg) => arg !== '--json');
|
|
33
|
+
// clig.dev: -h and --help must always work at any level.
|
|
34
|
+
tokens = tokens.map((arg) => (arg === '-h' ? '--help' : arg));
|
|
35
|
+
if (tokens[0] === 'help') {
|
|
36
|
+
tokens = [...tokens.slice(1), '--help'];
|
|
37
|
+
}
|
|
38
|
+
const helpRequested = tokens.includes('--help');
|
|
39
|
+
let core = tokens.filter((arg) => arg !== '--help');
|
|
40
|
+
const firstArg = core[0];
|
|
41
|
+
// Treat any first arg that looks like a URL (carries a scheme) as the `fetch` shorthand.
|
|
42
|
+
// Match both `https://…` and scheme-only forms like `javascript:` or `data:` so fetch can
|
|
43
|
+
// reject unsupported protocols instead of oclif reporting a misleading "command not found".
|
|
44
|
+
const looksLikeUrl = firstArg != null && (firstArg.includes('://') || /^[a-z][a-z0-9+.-]*:/i.test(firstArg));
|
|
45
|
+
if (looksLikeUrl) {
|
|
46
|
+
core = ['fetch', ...core];
|
|
47
|
+
}
|
|
48
|
+
if (helpRequested)
|
|
49
|
+
core.push('--help');
|
|
50
|
+
if (jsonMode)
|
|
51
|
+
core.push('--json');
|
|
52
|
+
return { argv: core };
|
|
53
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { type ConfigValues, type ResolvedConfig, type ConfigKey, type StorageMode, type SummaryLevel, type KeyMeta, SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
|
|
2
2
|
export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
|
|
3
|
-
export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, type ResolveStorageInput, type ResolveSummaryInput, } from './resolve.js';
|
|
3
|
+
export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, type ResolveStorageInput, type ResolveSummaryInput, } from './resolve.js';
|
|
4
4
|
import type { ConfigValues, ResolvedConfig, StorageMode, SummaryLevel } from './schema.js';
|
|
5
5
|
/**
|
|
6
6
|
* Read both config files once and resolve the effective storage mode.
|
package/dist/lib/config/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
|
|
2
2
|
export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
|
|
3
|
-
export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, } from './resolve.js';
|
|
3
|
+
export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, } from './resolve.js';
|
|
4
4
|
import { readUserConfig, readProjectConfig } from './io.js';
|
|
5
5
|
import { resolveStorageMode, resolveSummaryLevel } from './resolve.js';
|
|
6
6
|
/**
|
|
@@ -2,6 +2,13 @@ import type { ConfigValues, StorageMode, SummaryLevel } from './schema.js';
|
|
|
2
2
|
/** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
|
|
3
3
|
export declare const STORAGE_ENV_VAR = "BONSAI_STORAGE";
|
|
4
4
|
export declare const SUMMARY_ENV_VAR = "BONSAI_SUMMARY";
|
|
5
|
+
/**
|
|
6
|
+
* Warnings for Bonsai env overrides that are set but hold an unrecognized value. Such a value is
|
|
7
|
+
* dropped during resolution (falling back to config/default), which would otherwise silently mask
|
|
8
|
+
* a typo like `BONSAI_SUMMARY=agressive`. Callers surface these on stderr so the misconfiguration
|
|
9
|
+
* is visible without breaking the run. Returns one message per offending variable, or empty.
|
|
10
|
+
*/
|
|
11
|
+
export declare function invalidEnvOverrideWarnings(env: Record<string, string | undefined>): string[];
|
|
5
12
|
export declare function parseEnvStorage(env: Record<string, string | undefined>): StorageMode | undefined;
|
|
6
13
|
export declare function parseEnvSummary(env: Record<string, string | undefined>): SummaryLevel | undefined;
|
|
7
14
|
export interface ResolveStorageInput {
|
|
@@ -2,6 +2,29 @@ import { BUILT_IN_DEFAULTS, STORAGE_MODES, SUMMARY_LEVELS } from './schema.js';
|
|
|
2
2
|
/** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
|
|
3
3
|
export const STORAGE_ENV_VAR = 'BONSAI_STORAGE';
|
|
4
4
|
export const SUMMARY_ENV_VAR = 'BONSAI_SUMMARY';
|
|
5
|
+
/**
|
|
6
|
+
* Warnings for Bonsai env overrides that are set but hold an unrecognized value. Such a value is
|
|
7
|
+
* dropped during resolution (falling back to config/default), which would otherwise silently mask
|
|
8
|
+
* a typo like `BONSAI_SUMMARY=agressive`. Callers surface these on stderr so the misconfiguration
|
|
9
|
+
* is visible without breaking the run. Returns one message per offending variable, or empty.
|
|
10
|
+
*/
|
|
11
|
+
export function invalidEnvOverrideWarnings(env) {
|
|
12
|
+
const checks = [
|
|
13
|
+
[STORAGE_ENV_VAR, STORAGE_MODES],
|
|
14
|
+
[SUMMARY_ENV_VAR, SUMMARY_LEVELS],
|
|
15
|
+
];
|
|
16
|
+
const warnings = [];
|
|
17
|
+
for (const [name, valid] of checks) {
|
|
18
|
+
// After trim, an empty/whitespace-only value is falsy and treated as "unset" (no warning),
|
|
19
|
+
// matching parseEnv*; matching is case-sensitive, so a wrong-case value is invalid and warns.
|
|
20
|
+
const raw = env[name]?.trim();
|
|
21
|
+
if (raw && !valid.includes(raw)) {
|
|
22
|
+
warnings.push(`Ignoring ${name}="${raw}": not one of ${valid.join(', ')}. ` +
|
|
23
|
+
`Using the configured value or default instead.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return warnings;
|
|
27
|
+
}
|
|
5
28
|
export function parseEnvStorage(env) {
|
|
6
29
|
const raw = env[STORAGE_ENV_VAR]?.trim();
|
|
7
30
|
return STORAGE_MODES.includes(raw ?? '')
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Fields oclif attaches to CLIError that human pretty-print surfaces but JSON mode must mirror. */
|
|
2
|
+
export interface CliErrorShape {
|
|
3
|
+
message?: string;
|
|
4
|
+
code?: string;
|
|
5
|
+
suggestions?: string[];
|
|
6
|
+
ref?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Map oclif parse-time errors to stable Bonsai codes when the throw site did not set one. */
|
|
9
|
+
export declare function stableErrorCodeFrom(err: unknown): string | undefined;
|
|
10
|
+
/** Strip oclif's generic help suffix from JSON stderr — agents already have structured codes. */
|
|
11
|
+
export declare function normalizeCliErrorMessage(message: string): string;
|
|
12
|
+
export interface EnvelopeParts {
|
|
13
|
+
command: string;
|
|
14
|
+
ok: boolean;
|
|
15
|
+
exitCode: number;
|
|
16
|
+
stderr: string;
|
|
17
|
+
data: unknown;
|
|
18
|
+
/** Stable machine-readable error code when the failure is a CLIError with `code`. */
|
|
19
|
+
code?: string;
|
|
20
|
+
suggestions?: string[];
|
|
21
|
+
ref?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Formats a CLI error for the JSON envelope `stderr` field — same lines as human pretty-print,
|
|
25
|
+
* without ANSI or the leading "Error:" prefix (the message is already the primary line).
|
|
26
|
+
*/
|
|
27
|
+
export declare function formatErrorForJson(err: CliErrorShape): string;
|
|
28
|
+
/**
|
|
29
|
+
* Builds the standard Bonsai CLI JSON envelope structure.
|
|
30
|
+
* This is the single source of truth for the output envelope schema.
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildEnvelope(parts: EnvelopeParts): Record<string, unknown>;
|
|
33
|
+
/**
|
|
34
|
+
* Formats a JSON envelope to a string with indentation.
|
|
35
|
+
*/
|
|
36
|
+
export declare function formatEnvelope(parts: EnvelopeParts): string;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Map oclif parse-time errors to stable Bonsai codes when the throw site did not set one. */
|
|
2
|
+
export function stableErrorCodeFrom(err) {
|
|
3
|
+
const e = err;
|
|
4
|
+
if (typeof e?.code === 'string' && e.code)
|
|
5
|
+
return e.code;
|
|
6
|
+
switch (e?.constructor?.name) {
|
|
7
|
+
case 'RequiredArgsError':
|
|
8
|
+
return 'MISSING_ARGUMENT';
|
|
9
|
+
case 'FlagInvalidOptionError':
|
|
10
|
+
case 'ArgInvalidOptionError':
|
|
11
|
+
return 'INVALID_FLAG_VALUE';
|
|
12
|
+
case 'NonExistentFlagsError':
|
|
13
|
+
return 'UNKNOWN_FLAG';
|
|
14
|
+
case 'UnexpectedArgsError':
|
|
15
|
+
return 'UNEXPECTED_ARGUMENT';
|
|
16
|
+
default:
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Strip oclif's generic help suffix from JSON stderr — agents already have structured codes. */
|
|
21
|
+
export function normalizeCliErrorMessage(message) {
|
|
22
|
+
const suffix = '\nSee more help with --help';
|
|
23
|
+
return message.endsWith(suffix) ? message.slice(0, -suffix.length) : message;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Formats a CLI error for the JSON envelope `stderr` field — same lines as human pretty-print,
|
|
27
|
+
* without ANSI or the leading "Error:" prefix (the message is already the primary line).
|
|
28
|
+
*/
|
|
29
|
+
export function formatErrorForJson(err) {
|
|
30
|
+
const lines = [];
|
|
31
|
+
if (err.message)
|
|
32
|
+
lines.push(err.message);
|
|
33
|
+
if (err.code)
|
|
34
|
+
lines.push(`Code: ${err.code}`);
|
|
35
|
+
if (err.suggestions?.length) {
|
|
36
|
+
if (err.suggestions.length === 1) {
|
|
37
|
+
lines.push(`Try this: ${err.suggestions[0]}`);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
lines.push('Try this:');
|
|
41
|
+
for (const suggestion of err.suggestions)
|
|
42
|
+
lines.push(`* ${suggestion}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (err.ref)
|
|
46
|
+
lines.push(`Reference: ${err.ref}`);
|
|
47
|
+
return lines.join('\n');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Builds the standard Bonsai CLI JSON envelope structure.
|
|
51
|
+
* This is the single source of truth for the output envelope schema.
|
|
52
|
+
*/
|
|
53
|
+
export function buildEnvelope(parts) {
|
|
54
|
+
const envelope = {
|
|
55
|
+
schemaVersion: 1,
|
|
56
|
+
command: parts.command,
|
|
57
|
+
ok: parts.ok,
|
|
58
|
+
exitCode: parts.exitCode,
|
|
59
|
+
stdout: '',
|
|
60
|
+
stderr: parts.stderr,
|
|
61
|
+
data: parts.data,
|
|
62
|
+
};
|
|
63
|
+
if (parts.code)
|
|
64
|
+
envelope.code = parts.code;
|
|
65
|
+
if (parts.suggestions?.length)
|
|
66
|
+
envelope.suggestions = parts.suggestions;
|
|
67
|
+
if (parts.ref)
|
|
68
|
+
envelope.ref = parts.ref;
|
|
69
|
+
return envelope;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Formats a JSON envelope to a string with indentation.
|
|
73
|
+
*/
|
|
74
|
+
export function formatEnvelope(parts) {
|
|
75
|
+
return JSON.stringify(buildEnvelope(parts), null, 2);
|
|
76
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** True when argv asks for help or version together with --json. */
|
|
2
|
+
export declare function isJsonMetaRequest(argv: readonly string[]): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* When `--json` is combined with `--help` or `--version`, oclif would print human text to stdout.
|
|
5
|
+
* Render the same content inside the standard Bonsai JSON envelope instead.
|
|
6
|
+
*/
|
|
7
|
+
export declare function tryJsonMetaOutput(argv: readonly string[], root: string): Promise<{
|
|
8
|
+
exitCode: number;
|
|
9
|
+
envelope: Record<string, unknown>;
|
|
10
|
+
} | null>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Config, Help, toConfiguredId } from '@oclif/core';
|
|
2
|
+
import { buildEnvelope } from './envelope.js';
|
|
3
|
+
/** Strip ANSI color codes so JSON help text stays machine-stable. */
|
|
4
|
+
function stripAnsi(text) {
|
|
5
|
+
// eslint-disable-next-line no-control-regex
|
|
6
|
+
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
7
|
+
}
|
|
8
|
+
/** True when argv asks for help or version together with --json. */
|
|
9
|
+
export function isJsonMetaRequest(argv) {
|
|
10
|
+
if (!argv.includes('--json'))
|
|
11
|
+
return false;
|
|
12
|
+
return argv.includes('--help') || argv.includes('--version');
|
|
13
|
+
}
|
|
14
|
+
class CaptureHelp extends Help {
|
|
15
|
+
lines = [];
|
|
16
|
+
log(...args) {
|
|
17
|
+
for (const line of args) {
|
|
18
|
+
// oclif emits section separators as log(''); drop empties so data.help stays compact.
|
|
19
|
+
if (line)
|
|
20
|
+
this.lines.push(line);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function helpSubject(argv) {
|
|
25
|
+
for (const arg of argv) {
|
|
26
|
+
if (arg === '--' || arg === '--help' || arg === 'help')
|
|
27
|
+
continue;
|
|
28
|
+
if (arg.startsWith('-'))
|
|
29
|
+
return;
|
|
30
|
+
return arg;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
function envelopeCommandId(config, argv) {
|
|
35
|
+
const subject = helpSubject(argv);
|
|
36
|
+
if (!subject)
|
|
37
|
+
return config.bin;
|
|
38
|
+
const command = config.findCommand(subject);
|
|
39
|
+
if (command?.id)
|
|
40
|
+
return toConfiguredId(command.id, config);
|
|
41
|
+
const topic = config.findTopic(subject);
|
|
42
|
+
if (topic)
|
|
43
|
+
return topic.name;
|
|
44
|
+
return subject;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* When `--json` is combined with `--help` or `--version`, oclif would print human text to stdout.
|
|
48
|
+
* Render the same content inside the standard Bonsai JSON envelope instead.
|
|
49
|
+
*/
|
|
50
|
+
export async function tryJsonMetaOutput(argv, root) {
|
|
51
|
+
if (!isJsonMetaRequest(argv))
|
|
52
|
+
return null;
|
|
53
|
+
try {
|
|
54
|
+
const config = await Config.load({ root });
|
|
55
|
+
if (argv.includes('--version')) {
|
|
56
|
+
return {
|
|
57
|
+
exitCode: 0,
|
|
58
|
+
envelope: buildEnvelope({
|
|
59
|
+
command: config.bin,
|
|
60
|
+
ok: true,
|
|
61
|
+
exitCode: 0,
|
|
62
|
+
stderr: '',
|
|
63
|
+
data: {
|
|
64
|
+
version: config.version,
|
|
65
|
+
userAgent: config.userAgent,
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const help = new CaptureHelp(config, config.pjson.oclif?.helpOptions ?? {});
|
|
71
|
+
await help.showHelp([...argv]);
|
|
72
|
+
const helpText = stripAnsi(help.lines.join('\n').trimEnd());
|
|
73
|
+
return {
|
|
74
|
+
exitCode: 0,
|
|
75
|
+
envelope: buildEnvelope({
|
|
76
|
+
command: envelopeCommandId(config, argv),
|
|
77
|
+
ok: true,
|
|
78
|
+
exitCode: 0,
|
|
79
|
+
stderr: '',
|
|
80
|
+
data: { help: helpText },
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
86
|
+
return {
|
|
87
|
+
exitCode: 1,
|
|
88
|
+
envelope: buildEnvelope({
|
|
89
|
+
command: 'bonsai',
|
|
90
|
+
ok: false,
|
|
91
|
+
exitCode: 1,
|
|
92
|
+
stderr: message,
|
|
93
|
+
data: null,
|
|
94
|
+
code: 'META_RENDER_FAILED',
|
|
95
|
+
}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|