@haposoft/cafekit 0.18.2 → 0.18.4
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 +1 -1
- package/bin/__tests__/adv-skill.test.js +370 -0
- package/bin/__tests__/agent-authority.test.js +38 -0
- package/bin/__tests__/agent-instructions.test.js +54 -0
- package/bin/__tests__/claude-plugin-build.test.js +151 -0
- package/bin/__tests__/codex-hook-runtime-fixture.cjs +26 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +8 -11
- package/bin/__tests__/codex-hooks.test.js +87 -85
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native-hook-boundaries.test.js +2 -4
- package/bin/__tests__/codex-native.test.js +136 -2298
- package/bin/__tests__/codex-plugin-build.test.js +230 -0
- package/bin/__tests__/codex-plugin-context.test.js +135 -0
- package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +2 -8
- package/bin/__tests__/codex-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -0
- package/bin/__tests__/codex-skill-projections.test.js +101 -0
- package/bin/__tests__/develop-contract.test.js +100 -874
- package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
- package/bin/__tests__/migrate-apply.test.js +373 -0
- package/bin/__tests__/migrate-cli.test.js +152 -0
- package/bin/__tests__/migrate-merged.test.js +269 -0
- package/bin/__tests__/migrate-plan.test.js +271 -0
- package/bin/__tests__/omp-bridge.test.js +81 -5
- package/bin/__tests__/omp-hooks.test.js +24 -0
- package/bin/__tests__/orca-session.test.js +2 -16
- package/bin/__tests__/package-inventory.test.js +228 -2044
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +184 -0
- package/bin/__tests__/secret-scanner.test.js +73 -0
- package/bin/__tests__/skill-routing-source.test.js +6 -0
- package/bin/__tests__/toaa-skill.test.js +195 -0
- package/bin/__tests__/usage-hook-retired.test.js +24 -6
- package/bin/__tests__/validator-grounding.test.js +0 -198
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +548 -0
- package/bin/__tests__/website-docs.test.js +58 -0
- package/bin/install.js +99 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +12 -3
- package/bin/lib/context.js +5 -0
- package/bin/lib/lock.js +98 -18
- package/bin/lib/managed-writer.js +16 -10
- package/bin/lib/migrate-apply.js +202 -0
- package/bin/lib/migrate-merged.js +342 -0
- package/bin/lib/migrate-plan.js +419 -0
- package/bin/lib/plugin-inventory.js +33 -0
- package/bin/phases/claude-runtime.js +5 -3
- package/bin/phases/codex-hooks.js +38 -0
- package/bin/phases/codex-runtime.js +106 -74
- package/bin/phases/copy-payload.js +49 -1
- package/bin/phases/omp-runtime.js +4 -26
- package/bin/phases/root-config.js +5 -2
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +1 -1
- package/src/claude/agents/advisor.md +55 -0
- package/src/claude/agents/debugger.md +2 -3
- package/src/claude/agents/deployer.md +2 -1
- package/src/claude/agents/docs-keeper.md +12 -0
- package/src/claude/agents/inspector.md +1 -1
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/researcher.md +0 -1
- package/src/claude/agents/spec-maker.md +4 -1
- package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
- package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
- package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
- package/src/claude/hooks/__tests__/precompact.test.js +7 -1
- package/src/claude/hooks/__tests__/privacy-block.test.js +33 -35
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +86 -48
- package/src/claude/hooks/__tests__/state.test.js +21 -0
- package/src/claude/hooks/agent.cjs +5 -2
- package/src/claude/hooks/inspect-block.cjs +3 -1
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
- package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
- package/src/claude/hooks/lib/spec-touch.cjs +14 -1
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/privacy-block.cjs +2 -1
- package/src/claude/hooks/rules.cjs +5 -2
- package/src/claude/hooks/session.cjs +4 -1
- package/src/claude/hooks/spec-state.cjs +5 -4
- package/src/claude/hooks/state.cjs +27 -1
- package/src/claude/migration-manifest.json +7 -1
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- package/src/claude/rules/manage-docs.md +1 -2
- package/src/claude/rules/review-audit-self-decision.md +1 -1
- package/src/claude/rules/skill-workflow-routing.md +1 -1
- package/src/claude/rules/state-sync.md +14 -0
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/scripts/generate-skill-catalog.cjs +5 -0
- package/src/claude/scripts/provenance.cjs +40 -16
- package/src/claude/scripts/scan-staged-secrets.cjs +49 -3
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- package/src/claude/skills/adv/SKILL.md +139 -0
- package/src/claude/skills/adv/references/optional-modes.md +41 -0
- package/src/claude/skills/adv/references/ultra-mode.md +29 -0
- package/src/claude/skills/brainstorm/SKILL.md +1 -1
- package/src/claude/skills/code-review/SKILL.md +4 -1
- package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
- package/src/claude/skills/develop/SKILL.md +11 -3
- package/src/claude/skills/develop/references/quality-gate.md +1 -1
- package/src/claude/skills/fix/SKILL.md +2 -2
- package/src/claude/skills/git/SKILL.md +4 -2
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/review.md +1 -1
- package/src/claude/skills/specs/references/templates.md +2 -0
- package/src/claude/skills/sync/SKILL.md +4 -0
- package/src/claude/skills/toaa/SKILL.md +82 -0
- package/src/claude/skills/toaa/references/autonomous-orchestration.md +54 -0
- package/src/claude/skills/toaa/references/prerequisites-checklist.md +37 -0
- package/src/claude/skills/vibe/SKILL.md +69 -0
- package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
- package/src/claude/skills/vibe/references/report-templates.md +84 -0
- package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
- package/src/codex/AGENTS.md +1 -1
- package/src/codex/hooks/inspect-block.cjs +7 -4
- package/src/codex/hooks/lib/hook-context.cjs +119 -4
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-state.cjs +18 -1
- package/src/codex/hooks/lib/state-store.cjs +31 -6
- package/src/codex/hooks/precompact.cjs +9 -8
- package/src/codex/hooks/privacy-approval.cjs +2 -2
- package/src/codex/hooks/privacy-block.cjs +5 -4
- package/src/codex/hooks/rules.cjs +12 -6
- package/src/codex/hooks/session.cjs +22 -10
- package/src/codex/plugin/core.md +76 -0
- package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
- package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
- package/src/codex/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
- package/src/omp/hooks/privacy-block.cjs +2 -1
- package/bin/__tests__/codex-research-projection.test.js +0 -70
- package/bin/__tests__/codex-specs-projection.test.js +0 -76
- package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -58
- package/src/claude/skills/chrome-devtools/scripts/__tests__/error-handling.test.js +0 -102
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// a real subprocess: a Stop payload is piped to stdin; we assert exit code and
|
|
5
5
|
// whether stdout carries {"decision":"block",...}. The hook resolves its own
|
|
6
6
|
// state directory; running from source that is a temp directory rather than the
|
|
7
|
-
// source tree,
|
|
7
|
+
// source tree, so each test seeds/clears it deliberately.
|
|
8
8
|
|
|
9
9
|
const { test, beforeEach, after } = require('node:test');
|
|
10
10
|
const assert = require('node:assert');
|
|
@@ -14,6 +14,11 @@ const fs = require('node:fs');
|
|
|
14
14
|
const os = require('node:os');
|
|
15
15
|
const path = require('node:path');
|
|
16
16
|
|
|
17
|
+
// Hooks run from source keep state under os.tmpdir(); a private one per test process
|
|
18
|
+
// keeps a parallel run of this file from reading or clearing this run's state.
|
|
19
|
+
const PRIVATE_TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-gate-tmp-'));
|
|
20
|
+
for (const key of ['TMPDIR', 'TMP', 'TEMP']) process.env[key] = PRIVATE_TMP;
|
|
21
|
+
|
|
17
22
|
const HOOK = path.join(__dirname, '..', 'spec-gate.cjs');
|
|
18
23
|
const { hookStateDir } = require(path.join(__dirname, '..', 'lib', 'hook-state-dir.cjs'));
|
|
19
24
|
const CACHE = path.join(hookStateDir(), 'spec-gate-last.json');
|
|
@@ -291,6 +296,7 @@ beforeEach(() => {
|
|
|
291
296
|
|
|
292
297
|
after(() => {
|
|
293
298
|
clearCache();
|
|
299
|
+
fs.rmSync(PRIVATE_TMP, { recursive: true, force: true });
|
|
294
300
|
});
|
|
295
301
|
|
|
296
302
|
test('1. newly-done task WITHOUT receipt → block, reason names task path', () => {
|
|
@@ -462,32 +468,6 @@ test('4. stop_hook_active: true → exit 0 silent even with violating task', ()
|
|
|
462
468
|
}
|
|
463
469
|
});
|
|
464
470
|
|
|
465
|
-
test('4b. done task before explicit closeout without receipt blocks', () => {
|
|
466
|
-
const dir = makeFixture({ evidence: 'missing-evidence', phase: 'implementation' });
|
|
467
|
-
try {
|
|
468
|
-
const { code, stdout } = runHook({}, dir);
|
|
469
|
-
assert.strictEqual(code, 0);
|
|
470
|
-
const body = parseBlock(stdout);
|
|
471
|
-
assert.strictEqual(body?.decision, 'block');
|
|
472
|
-
assert.match(body.reason, new RegExp(TASK_REL));
|
|
473
|
-
assert.strictEqual(fs.existsSync(path.join(dir, 'specs', FEATURE, 'feature-receipt.md')), false);
|
|
474
|
-
} finally {
|
|
475
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
476
|
-
}
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
test('4c. done task before explicit closeout with valid task receipt is silent', () => {
|
|
480
|
-
const dir = makeFixture({ evidence: 'valid', phase: 'implementation' });
|
|
481
|
-
try {
|
|
482
|
-
const { code, stdout } = runHook({}, dir);
|
|
483
|
-
assert.strictEqual(code, 0);
|
|
484
|
-
assert.strictEqual(stdout, '');
|
|
485
|
-
assert.strictEqual(fs.existsSync(path.join(dir, 'specs', FEATURE, 'feature-receipt.md')), false);
|
|
486
|
-
} finally {
|
|
487
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
488
|
-
}
|
|
489
|
-
});
|
|
490
|
-
|
|
491
471
|
test('5. first run (no cache) with done-without-receipt → block (cache is not truth)', () => {
|
|
492
472
|
const dir = makeFixture({ evidence: 'missing-evidence' });
|
|
493
473
|
try {
|
|
@@ -895,27 +875,6 @@ test('25. placeholder tokens Command: TODO / Base: TBD / artifact sha256 empty m
|
|
|
895
875
|
fs.rmSync(dir2, { recursive: true, force: true });
|
|
896
876
|
});
|
|
897
877
|
|
|
898
|
-
test('26. explicit failure outcomes Tests failed / Result FAIL and multiple Results must block', () => {
|
|
899
|
-
const cases = [
|
|
900
|
-
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Tests failed: 1'].join('\n'), desc: 'Tests failed: 1' },
|
|
901
|
-
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Result: FAIL','Result: PASS'].join('\n'), desc: 'Result FAIL then PASS' },
|
|
902
|
-
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','0 tests passed'].join('\n'), desc: '0 tests' },
|
|
903
|
-
];
|
|
904
|
-
for (const { evidence, desc } of cases) {
|
|
905
|
-
const dir = tmpDir();
|
|
906
|
-
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
907
|
-
fs.mkdirSync(featureDir, { recursive: true });
|
|
908
|
-
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
|
|
909
|
-
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
910
|
-
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','Status: done','','## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: pnpm test', '', '## Receipt','','' + evidence,'```','pass','```'].join('\n'));
|
|
911
|
-
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
912
|
-
const body = parseBlock(runHook({}, dir).stdout);
|
|
913
|
-
assert.ok(body, `explicit failure ${desc} should block`);
|
|
914
|
-
assert.strictEqual(body.decision, 'block');
|
|
915
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
916
|
-
}
|
|
917
|
-
});
|
|
918
|
-
|
|
919
878
|
test('27. task markdown symlink outside feature must be rejected (check a)', () => {
|
|
920
879
|
const dir = tmpDir();
|
|
921
880
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
@@ -1997,3 +1956,82 @@ test('a packet holding both plan.md and spec.json is read as process-first and i
|
|
|
1997
1956
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
1998
1957
|
}
|
|
1999
1958
|
});
|
|
1959
|
+
|
|
1960
|
+
// Head is sha256 over one JSON line per path with the file bytes as base64. It is now streamed instead of built, and
|
|
1961
|
+
// must stay byte-identical: every receipt already written carries a Head computed the old way.
|
|
1962
|
+
function referenceHead(dir) {
|
|
1963
|
+
const git = (...args) => spawnSync('git', ['-C', dir, ...args], { encoding: 'buffer' }).stdout;
|
|
1964
|
+
const split = (buffer) => buffer.toString('utf8').split('\0').filter(Boolean);
|
|
1965
|
+
const raw = split(git('status', '--porcelain=v1', '-z', '--untracked-files=all'));
|
|
1966
|
+
const statuses = new Map();
|
|
1967
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
1968
|
+
const xy = raw[index].slice(0, 2);
|
|
1969
|
+
const first = raw[index].slice(3);
|
|
1970
|
+
if (xy.includes('R') || xy.includes('C')) {
|
|
1971
|
+
const next = raw[++index];
|
|
1972
|
+
statuses.set(first, { xy, role: 'rename-source', counterpart: next });
|
|
1973
|
+
statuses.set(next, { xy, role: 'rename-target', counterpart: first });
|
|
1974
|
+
} else statuses.set(first, { xy, role: xy === '??' || xy === '!!' ? 'untracked' : 'tracked' });
|
|
1975
|
+
}
|
|
1976
|
+
const paths = [...new Set([...split(git('ls-files', '-z', '--cached')), ...split(git('ls-tree', '-r', '-z', '--name-only', 'HEAD')), ...statuses.keys()])]
|
|
1977
|
+
.filter((relative) => !relative.startsWith('specs/') && !PROVENANCE.RUNTIME_STATE_ROOTS.some((root) => relative === root || relative.startsWith(`${root}/`)))
|
|
1978
|
+
.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
1979
|
+
const hash = crypto.createHash('sha256').update('cafekit-provenance\0');
|
|
1980
|
+
for (const relative of paths) {
|
|
1981
|
+
const absolute = path.join(dir, relative);
|
|
1982
|
+
const status = statuses.get(relative) || null;
|
|
1983
|
+
let stat;
|
|
1984
|
+
try { stat = fs.lstatSync(absolute); } catch {
|
|
1985
|
+
hash.update(`${JSON.stringify({ path: relative, type: 'deleted', mode: '0000', status, content: null })}\n`);
|
|
1986
|
+
continue;
|
|
1987
|
+
}
|
|
1988
|
+
const type = stat.isSymbolicLink() ? 'symlink' : stat.isFile() ? 'file' : 'directory';
|
|
1989
|
+
const content = type === 'symlink' ? Buffer.from(fs.readlinkSync(absolute), 'utf8') : type === 'file' ? fs.readFileSync(absolute) : Buffer.alloc(0);
|
|
1990
|
+
const mode = (stat.mode & 0o7777).toString(8).padStart(4, '0');
|
|
1991
|
+
hash.update(`${JSON.stringify({ path: relative, type, mode, status, content: content.toString('base64') })}\n`);
|
|
1992
|
+
}
|
|
1993
|
+
return hash.digest('hex');
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
function provenanceFixture() {
|
|
1997
|
+
const dir = fs.realpathSync(tmpDir());
|
|
1998
|
+
fs.mkdirSync(path.join(dir, 'specs', FEATURE), { recursive: true });
|
|
1999
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, 'plan.md'), '# Demo plan\n');
|
|
2000
|
+
return dir;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
test('Head is unchanged by streaming: files around the 3 MB chunk edge, symlink, rename, deletion, quoted names', () => {
|
|
2004
|
+
const dir = provenanceFixture();
|
|
2005
|
+
try {
|
|
2006
|
+
const chunk = 3 * 1024 * 1024;
|
|
2007
|
+
for (const size of [0, 1, 2, 4, chunk - 1, chunk, chunk + 1, 2 * chunk + 2]) {
|
|
2008
|
+
fs.writeFileSync(path.join(dir, `bin-${size}.dat`), crypto.randomBytes(size));
|
|
2009
|
+
}
|
|
2010
|
+
fs.writeFileSync(path.join(dir, 'tiếng "việt" \\ name.txt'), 'x');
|
|
2011
|
+
fs.writeFileSync(path.join(dir, 'run.sh'), '#!/bin/sh\n');
|
|
2012
|
+
fs.chmodSync(path.join(dir, 'run.sh'), 0o755);
|
|
2013
|
+
fs.writeFileSync(path.join(dir, 'gone.txt'), 'g');
|
|
2014
|
+
fs.writeFileSync(path.join(dir, 'old-name.txt'), 'r');
|
|
2015
|
+
for (const args of [['add', '-A'], ['commit', '-qm', 'files']]) assert.strictEqual(spawnSync('git', ['-C', dir, ...args]).status, 0);
|
|
2016
|
+
fs.unlinkSync(path.join(dir, 'gone.txt'));
|
|
2017
|
+
assert.strictEqual(spawnSync('git', ['-C', dir, 'mv', 'old-name.txt', 'new-name.txt']).status, 0);
|
|
2018
|
+
fs.symlinkSync('run.sh', path.join(dir, 'link'));
|
|
2019
|
+
fs.writeFileSync(path.join(dir, 'untracked.bin'), crypto.randomBytes(1000));
|
|
2020
|
+
assert.strictEqual(workflowRuntimeContext(dir).head, referenceHead(dir));
|
|
2021
|
+
} finally {
|
|
2022
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
2023
|
+
}
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
test('a checkout whose files exceed the longest string V8 can hold still yields a Head', () => {
|
|
2027
|
+
const dir = provenanceFixture();
|
|
2028
|
+
try {
|
|
2029
|
+
// Sparse: 600 MB of zeros on disk costs nothing, but its base64 alone (800M characters) is past V8's string limit.
|
|
2030
|
+
const descriptor = fs.openSync(path.join(dir, 'large-asset.bin'), 'w');
|
|
2031
|
+
fs.ftruncateSync(descriptor, 600 * 1024 * 1024);
|
|
2032
|
+
fs.closeSync(descriptor);
|
|
2033
|
+
assert.match(workflowRuntimeContext(dir).head, /^[0-9a-f]{64}$/);
|
|
2034
|
+
} finally {
|
|
2035
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
2036
|
+
}
|
|
2037
|
+
});
|
|
@@ -485,6 +485,27 @@ test('SubagentStop saves the assistant summary and omits secret-related content'
|
|
|
485
485
|
});
|
|
486
486
|
});
|
|
487
487
|
|
|
488
|
+
test('a symlinked session-state path is refused and nothing is written outside the project', () => {
|
|
489
|
+
inSessionStateFixture(({ invoke, stateRoot }) => {
|
|
490
|
+
// A repository can commit either the shared state folder or one session folder as a link.
|
|
491
|
+
for (const link of [stateRoot, savedSession(stateRoot)]) {
|
|
492
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-state-outside-'));
|
|
493
|
+
try {
|
|
494
|
+
fs.rmSync(stateRoot, { recursive: true, force: true });
|
|
495
|
+
fs.mkdirSync(path.dirname(link), { recursive: true });
|
|
496
|
+
fs.symlinkSync(outside, link);
|
|
497
|
+
invoke({ hook_event_name: 'Stop', session_id: 'robust-session', last_assistant_message: 'Linked snapshot' });
|
|
498
|
+
assert.deepStrictEqual(fs.readdirSync(outside), [], `${path.basename(link)} link must not receive state`);
|
|
499
|
+
} finally {
|
|
500
|
+
fs.rmSync(stateRoot, { recursive: true, force: true });
|
|
501
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
invoke({ hook_event_name: 'Stop', session_id: 'robust-session', last_assistant_message: 'Plain snapshot' });
|
|
505
|
+
assert.ok(fs.existsSync(path.join(savedSession(stateRoot), 'latest.md')), 'a plain state folder still saves');
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
488
509
|
test('a fresh state write lock skips the snapshot without changing existing bytes', () => {
|
|
489
510
|
inSessionStateFixture(({ invoke, stateRoot }) => {
|
|
490
511
|
const dir = savedSession(stateRoot);
|
|
@@ -45,12 +45,15 @@ try {
|
|
|
45
45
|
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
46
46
|
const payload = normalizeHookPayload(JSON.parse(stdin));
|
|
47
47
|
const root = projectRoot(payload);
|
|
48
|
-
|
|
48
|
+
// An npx install always has runtime.json; under the plugin it is optional.
|
|
49
|
+
// Loaded only under the plugin, so npx installs need no extra hook file.
|
|
50
|
+
const plugin = process.env.CLAUDE_PLUGIN_ROOT ? require('./lib/plugin-options.cjs') : null;
|
|
51
|
+
const runtime = readRuntime(root) || (plugin ? {} : null);
|
|
49
52
|
if (runtime === null) process.exit(0);
|
|
50
53
|
|
|
51
54
|
// Language config from runtime.json
|
|
52
55
|
const thinkLang = runtime.locale?.thinkingLanguage || '';
|
|
53
|
-
const respondLang = runtime.locale?.responseLanguage || '';
|
|
56
|
+
const respondLang = runtime.locale?.responseLanguage || (plugin ? plugin.pluginOption('response_language') : '');
|
|
54
57
|
// Default thinking to 'en' when only response language is set
|
|
55
58
|
const effectThink = thinkLang || (respondLang ? 'en' : '');
|
|
56
59
|
|
|
@@ -60,7 +60,9 @@ function denyWithReason(reason) {
|
|
|
60
60
|
const root = projectRoot(data);
|
|
61
61
|
const runtime = readRuntime(root);
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
// A plugin is trusted once for every repository, so a repository cannot switch it off.
|
|
64
|
+
const disabled = runtime.scout?.enabled === false || runtime.inspect?.enabled === false;
|
|
65
|
+
if (disabled && !require('./lib/plugin-options.cjs').isPlugin()) process.exit(0);
|
|
64
66
|
|
|
65
67
|
const result = inspectInput(toolName, toolInput, {
|
|
66
68
|
cwd, projectRoot: root
|
|
@@ -29,11 +29,29 @@ function isSourceTree(dir) {
|
|
|
29
29
|
return String(dir || '').includes(SOURCE_MARKER);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* State root for hooks running from the Claude Code plugin, or null outside it.
|
|
34
|
+
* The plugin root is replaced on every update and the project must stay clean,
|
|
35
|
+
* so plugin state lives in CLAUDE_PLUGIN_DATA, or the OS temp directory when the
|
|
36
|
+
* host gives no usable data directory.
|
|
37
|
+
*/
|
|
38
|
+
function pluginStateRoot() {
|
|
39
|
+
if (!process.env.CLAUDE_PLUGIN_ROOT) return null;
|
|
40
|
+
const data = process.env.CLAUDE_PLUGIN_DATA;
|
|
41
|
+
if (data && path.isAbsolute(data)) return data;
|
|
42
|
+
// Per-user name so accounts sharing /tmp do not collide; this is a fallback
|
|
43
|
+
// only, since the host sets CLAUDE_PLUGIN_DATA for plugin hooks.
|
|
44
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
|
|
45
|
+
return path.join(os.tmpdir(), `cafekit-plugin-${uid}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
/** Absolute directory for hook-generated state; create it before writing. */
|
|
33
49
|
function hookStateDir() {
|
|
50
|
+
const pluginRoot = pluginStateRoot();
|
|
51
|
+
if (pluginRoot) return path.join(pluginRoot, '.logs');
|
|
34
52
|
return isSourceTree(HOOKS_DIR)
|
|
35
53
|
? path.join(os.tmpdir(), 'cafekit-hook-logs', require('./runtime-dir.cjs').runtimeDirName().slice(1))
|
|
36
54
|
: path.join(HOOKS_DIR, '.logs');
|
|
37
55
|
}
|
|
38
56
|
|
|
39
|
-
module.exports = { hookStateDir, isSourceTree };
|
|
57
|
+
module.exports = { hookStateDir, isSourceTree, pluginStateRoot };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code plugin options for the hooks.
|
|
5
|
+
*
|
|
6
|
+
* The plugin's userConfig values reach hooks as CLAUDE_PLUGIN_OPTION_<KEY> and are
|
|
7
|
+
* injected into model context verbatim, so each one is reduced to a single short
|
|
8
|
+
* line: no control, bidi, or zero-width characters or line breaks that could open a
|
|
9
|
+
* new section or disguise the text, and
|
|
10
|
+
* at most 64 characters so the core stays inside the host's context budget.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const MAX_OPTION_LENGTH = 64;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* True when the hook runs from the Claude Code plugin rather than an npx install.
|
|
20
|
+
* The variable alone is not enough: an npx hook can inherit it from a parent process,
|
|
21
|
+
* so this file must really live under that plugin root.
|
|
22
|
+
*/
|
|
23
|
+
function isPlugin() {
|
|
24
|
+
const declared = process.env.CLAUDE_PLUGIN_ROOT;
|
|
25
|
+
if (!declared) return false;
|
|
26
|
+
try {
|
|
27
|
+
return fs.realpathSync(declared) === fs.realpathSync(path.resolve(__dirname, '..', '..'));
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One short plain line: no control, bidi, or zero-width characters; at most 64 characters. */
|
|
34
|
+
function sanitizeLine(raw) {
|
|
35
|
+
if (typeof raw !== 'string') return '';
|
|
36
|
+
return [...raw.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]+/g, ' ').replace(/\s+/g, ' ').trim()]
|
|
37
|
+
.slice(0, MAX_OPTION_LENGTH)
|
|
38
|
+
.join('')
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Sanitized option value, or '' when unset. `key` is the userConfig key, e.g. `response_language`. */
|
|
43
|
+
function pluginOption(key) {
|
|
44
|
+
return sanitizeLine(process.env[`CLAUDE_PLUGIN_OPTION_${key.toUpperCase()}`]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { MAX_OPTION_LENGTH, isPlugin, pluginOption, sanitizeLine };
|
|
@@ -35,6 +35,8 @@ const SED_OPTION_VALUE = new Set(['-e', '--expression', '-f', '--file']);
|
|
|
35
35
|
const AWK_OPTION_VALUE = new Set(['-f', '--file', '-v', '--assign', '-F', '--field-separator']);
|
|
36
36
|
const XARGS_OPTION_VALUE = new Set(['-a', '--arg-file', '-d', '--delimiter', '-E', '--eof', '-I', '--replace', '-L', '--max-lines', '-n', '--max-args', '-P', '--max-procs', '-s', '--max-chars']);
|
|
37
37
|
|
|
38
|
+
const IDENTIFIER = /[A-Za-z_][A-Za-z0-9_]*/y;
|
|
39
|
+
|
|
38
40
|
function expansionAt(source, index) {
|
|
39
41
|
const next = source[index + 1];
|
|
40
42
|
return Boolean(next && (next === '{' || next === '('
|
|
@@ -107,18 +109,28 @@ function scanShell(command) {
|
|
|
107
109
|
const source = String(command).replace(/\\\r?\n/g, '');
|
|
108
110
|
const segments = [];
|
|
109
111
|
const nested = [];
|
|
110
|
-
|
|
112
|
+
// Each nested command is also kept on the word it sits in, so an operand built from $(...) carries its literals.
|
|
113
|
+
const recordNested = command => { nested.push({ command, segment: segments.length }); (word.nested ||= []).push(command); };
|
|
111
114
|
let words = [];
|
|
112
115
|
let word = { text: '', activeExpansion: false, started: false };
|
|
113
116
|
let quote = null;
|
|
114
117
|
let escaped = false;
|
|
115
118
|
let malformed = false;
|
|
116
119
|
const append = (value) => { word.text += value; word.started = true; };
|
|
120
|
+
// `$NAME` is written as `${NAME}` so a quote that ends the name (`.en"$Y"v`) still ends it after the quotes go.
|
|
121
|
+
const appendName = (index) => {
|
|
122
|
+
IDENTIFIER.lastIndex = index + 1;
|
|
123
|
+
const match = IDENTIFIER.exec(source);
|
|
124
|
+
if (!match) return index;
|
|
125
|
+
append(`{${match[0]}}`);
|
|
126
|
+
return index + match[0].length;
|
|
127
|
+
};
|
|
117
128
|
const flushWord = () => {
|
|
118
129
|
if (word.started || word.text) words.push(word);
|
|
119
130
|
word = { text: '', activeExpansion: false, started: false };
|
|
120
131
|
};
|
|
121
|
-
|
|
132
|
+
// `link` is how the segment ends: 'pipe' for a single |, anything else for ;, &&, ||, &, newline and parentheses.
|
|
133
|
+
const flushSegment = (link = 'end') => { flushWord(); if (words.length) { words.link = link; segments.push(words); } words = []; };
|
|
122
134
|
|
|
123
135
|
for (let index = 0; index < source.length; index += 1) {
|
|
124
136
|
const ch = source[index];
|
|
@@ -136,7 +148,7 @@ function scanShell(command) {
|
|
|
136
148
|
recordNested(source.slice(index + 2, end));
|
|
137
149
|
append('()');
|
|
138
150
|
index = end;
|
|
139
|
-
}
|
|
151
|
+
} else index = appendName(index);
|
|
140
152
|
continue;
|
|
141
153
|
}
|
|
142
154
|
if (ch === '\x60') {
|
|
@@ -168,7 +180,7 @@ function scanShell(command) {
|
|
|
168
180
|
recordNested(source.slice(index + 2, end));
|
|
169
181
|
append('()');
|
|
170
182
|
index = end;
|
|
171
|
-
}
|
|
183
|
+
} else index = appendName(index);
|
|
172
184
|
continue;
|
|
173
185
|
}
|
|
174
186
|
if (ch === '\x60') {
|
|
@@ -190,7 +202,11 @@ function scanShell(command) {
|
|
|
190
202
|
continue;
|
|
191
203
|
}
|
|
192
204
|
if ((ch === '~' && !word.text) || '*?{['.includes(ch)) { word.activeExpansion = true; append(ch); continue; }
|
|
193
|
-
if (';|&\n()'.includes(ch)) {
|
|
205
|
+
if (';|&\n()'.includes(ch)) {
|
|
206
|
+
const pipe = ch === '|' && source[index + 1] !== '|' && source[index - 1] !== '|' && source[index + 1] !== '&';
|
|
207
|
+
flushSegment(pipe ? 'pipe' : 'other');
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
194
210
|
if (ch === '<') {
|
|
195
211
|
const descriptor = /^[0-9]+$/.test(word.text);
|
|
196
212
|
flushWord();
|
|
@@ -233,9 +249,17 @@ function skipOption(words, index, valueOptions = WRAPPER_OPTION_VALUE) {
|
|
|
233
249
|
return valueOptions.has(value) ? index + 2 : index + 1;
|
|
234
250
|
}
|
|
235
251
|
|
|
252
|
+
// Words that open or continue a compound command; the command they lead into is still the one that runs.
|
|
253
|
+
const KEYWORDS = new Set(['if', 'then', 'elif', 'else', 'while', 'until', 'do', '{', '!', 'time', 'coproc', 'function']);
|
|
254
|
+
|
|
236
255
|
function unwrap(words) {
|
|
237
256
|
let index = 0;
|
|
238
|
-
while (index < words.length && assignment(words[index])
|
|
257
|
+
while (index < words.length && (assignment(words[index]) || KEYWORDS.has(words[index].text))) {
|
|
258
|
+
index += 1;
|
|
259
|
+
if (words[index - 1].text === 'time') while (words[index]?.text.startsWith('-')) index += 1;
|
|
260
|
+
// `function f { cat .env; }`: the word after `function` is the name being defined, not a command.
|
|
261
|
+
if (words[index - 1].text === 'function') index += 1;
|
|
262
|
+
}
|
|
239
263
|
for (let depth = 0; depth < 12 && index < words.length; depth += 1) {
|
|
240
264
|
const name = commandName(words[index]);
|
|
241
265
|
if (!WRAPPERS.has(name)) return { index, name };
|
|
@@ -260,8 +284,19 @@ function unwrap(words) {
|
|
|
260
284
|
|
|
261
285
|
function addOperand(result, word) {
|
|
262
286
|
if (!word) return;
|
|
263
|
-
if (word.activeExpansion)
|
|
264
|
-
|
|
287
|
+
if (word.activeExpansion) {
|
|
288
|
+
result.dynamic = true;
|
|
289
|
+
// Kept for the path resolver: the raw text, the directory a tracked `cd` left, and the literal words of every
|
|
290
|
+
// command substitution inside it. The assignments it can see are attached when its segment finishes.
|
|
291
|
+
result.expansions.push({ text: word.text, directory: result.directory === undefined ? '.' : result.directory,
|
|
292
|
+
literals: (word.nested || []).flatMap(literalWords) });
|
|
293
|
+
} else if (word.text && word.text !== '-') result.paths.add(word.text);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The literal words of a command line: no expansion, no option.
|
|
297
|
+
function literalWords(command) {
|
|
298
|
+
return scanShell(command).segments.flat().filter((word) => !word.activeExpansion && word.text && !word.text.startsWith('-'))
|
|
299
|
+
.map((word) => word.text);
|
|
265
300
|
}
|
|
266
301
|
|
|
267
302
|
function genericOperands(words, start, result, {
|
|
@@ -292,6 +327,9 @@ function merge(target, source) {
|
|
|
292
327
|
target.searches.push(...source.searches);
|
|
293
328
|
source.writes.forEach(value => target.writes.add(value));
|
|
294
329
|
source.unknownWrites.forEach(value => target.unknownWrites.add(value));
|
|
330
|
+
target.expansions.push(...source.expansions);
|
|
331
|
+
target.pipeNames.push(...source.pipeNames);
|
|
332
|
+
target.decodedRead ||= source.decodedRead;
|
|
295
333
|
target.dynamic ||= source.dynamic;
|
|
296
334
|
target.malformed ||= source.malformed;
|
|
297
335
|
}
|
|
@@ -432,6 +470,7 @@ function commandWrites(words, start, name, result, directory) {
|
|
|
432
470
|
}
|
|
433
471
|
|
|
434
472
|
function analyzeWords(words, result, depth, directory = '.') {
|
|
473
|
+
result.directory = directory;
|
|
435
474
|
for (const index of words.redirects || []) writeTarget(words[index], result, directory);
|
|
436
475
|
for (let index = 0; index + 1 < words.length; index += 1) {
|
|
437
476
|
if (words[index].text === '<') addOperand(result, words[index + 1]);
|
|
@@ -489,10 +528,16 @@ function analyzeWords(words, result, depth, directory = '.') {
|
|
|
489
528
|
return;
|
|
490
529
|
}
|
|
491
530
|
if (name === 'find') {
|
|
531
|
+
// A name test that selects a secret, handed to a reader through -exec, reads it.
|
|
532
|
+
const names = [];
|
|
533
|
+
for (let index = start; index + 1 < words.length; index += 1) {
|
|
534
|
+
if (['-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename'].includes(words[index].text)) names.push(words[index + 1].text);
|
|
535
|
+
}
|
|
492
536
|
for (let index = start; index < words.length; index += 1) {
|
|
493
537
|
if (words[index].text !== '-exec' && words[index].text !== '-execdir') continue;
|
|
494
538
|
const nested = [];
|
|
495
539
|
for (index += 1; index < words.length && ![';', '+'].includes(words[index].text); index += 1) nested.push(words[index]);
|
|
540
|
+
if (READS.has(unwrap(nested).name)) result.pipeNames.push(...names);
|
|
496
541
|
analyzeWords(nested, result, depth + 1, directory);
|
|
497
542
|
}
|
|
498
543
|
return;
|
|
@@ -528,15 +573,29 @@ function analyzeWords(words, result, depth, directory = '.') {
|
|
|
528
573
|
}
|
|
529
574
|
|
|
530
575
|
function analyzeCommand(command, depth = 0, directory = '.') {
|
|
531
|
-
const result = { paths: new Set(), dynamic: false, malformed: false, searches: [], writes: new Set(), unknownWrites: new Set()
|
|
576
|
+
const result = { paths: new Set(), dynamic: false, malformed: false, searches: [], writes: new Set(), unknownWrites: new Set(),
|
|
577
|
+
expansions: [], pipeNames: [], decodedRead: false };
|
|
532
578
|
if (depth > MAX_DEPTH) { result.dynamic = true; return result; }
|
|
533
579
|
const parsed = scanShell(command);
|
|
534
580
|
result.malformed = parsed.malformed;
|
|
535
581
|
let currentDirectory = directory;
|
|
536
582
|
const directories = [];
|
|
537
|
-
|
|
583
|
+
const assignments = [];
|
|
584
|
+
const visibleAt = [];
|
|
585
|
+
// Every operand found while analysing a segment -- its own, or one from a nested eval, `bash -c` or $(...) -- sees the
|
|
586
|
+
// assignments of the earlier segments. `f=.env cat $f` does not: the shell expands $f before that prefix applies.
|
|
587
|
+
const scope = (from, segmentIndex) => {
|
|
588
|
+
for (const expansion of result.expansions.slice(from)) {
|
|
589
|
+
expansion.scopes = [{ list: assignments, count: visibleAt[segmentIndex] }, ...(expansion.scopes || [])];
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
for (const [segmentIndex, words] of parsed.segments.entries()) {
|
|
538
593
|
directories.push(currentDirectory);
|
|
594
|
+
visibleAt.push(assignments.length);
|
|
595
|
+
recordAssignments(words, assignments);
|
|
596
|
+
const from = result.expansions.length;
|
|
539
597
|
analyzeWords(words, result, depth, currentDirectory);
|
|
598
|
+
scope(from, segmentIndex);
|
|
540
599
|
const info = unwrap(words);
|
|
541
600
|
if (['cd', 'pushd'].includes(info.name)) {
|
|
542
601
|
const target = words.slice(info.index + 1).find(word => word.text && !word.text.startsWith('-'));
|
|
@@ -545,9 +604,12 @@ function analyzeCommand(command, depth = 0, directory = '.') {
|
|
|
545
604
|
else if (currentDirectory !== null) currentDirectory = `${currentDirectory}/${target.text}`;
|
|
546
605
|
}
|
|
547
606
|
}
|
|
607
|
+
analyzePipelines(parsed.segments, result);
|
|
548
608
|
for (const nested of parsed.nested) {
|
|
549
609
|
const nestedDirectory = directories[nested.segment] === undefined ? directory : directories[nested.segment];
|
|
610
|
+
const from = result.expansions.length;
|
|
550
611
|
merge(result, analyzeCommand(nested.command, depth + 1, nestedDirectory));
|
|
612
|
+
if (visibleAt[nested.segment] !== undefined) scope(from, nested.segment);
|
|
551
613
|
}
|
|
552
614
|
if (result.malformed && parsed.segments.some((words) => {
|
|
553
615
|
const commandInfo = unwrap(words);
|
|
@@ -558,10 +620,67 @@ function analyzeCommand(command, depth = 0, directory = '.') {
|
|
|
558
620
|
return result;
|
|
559
621
|
}
|
|
560
622
|
|
|
623
|
+
const ASSIGNING = new Set(['export', 'declare', 'typeset', 'local', 'readonly']);
|
|
624
|
+
|
|
625
|
+
// A segment that only assigns (`f=.env`), or an export/declare/local/typeset/readonly. The value keeps its expansions
|
|
626
|
+
// (`f="$PWD/.env"`, `G=$F`); the resolver substitutes them in order.
|
|
627
|
+
function recordAssignments(words, out) {
|
|
628
|
+
const bare = words.every(assignment);
|
|
629
|
+
const builtin = ASSIGNING.has(commandName(words[0]));
|
|
630
|
+
if (!bare && !builtin) return;
|
|
631
|
+
for (const word of words.slice(bare ? 0 : 1)) {
|
|
632
|
+
if (!assignment(word)) continue;
|
|
633
|
+
const at = word.text.indexOf('=');
|
|
634
|
+
out.push({ name: word.text.slice(0, at), value: word.text.slice(at + 1) });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const DECODERS = (words) => {
|
|
639
|
+
const name = commandName(words[unwrap(words).index]);
|
|
640
|
+
const flags = words.map((word) => word.text);
|
|
641
|
+
if (name === 'base64' || name === 'basenc') return flags.some((flag) => ['-d', '-D', '--decode'].includes(flag));
|
|
642
|
+
if (name === 'openssl') return flags.some((flag) => ['base64', 'enc'].includes(flag)) && flags.includes('-d');
|
|
643
|
+
if (name === 'xxd') return flags.includes('-r') || flags.includes('-revert');
|
|
644
|
+
if (name === 'gpg') return flags.some((flag) => ['-d', '--decrypt'].includes(flag));
|
|
645
|
+
return ['uudecode', 'b64decode'].includes(name);
|
|
646
|
+
};
|
|
647
|
+
const READS = new Set([...SIMPLE_READERS, ...GREP_READERS, ...AWK_READERS, 'sed', 'source', '.']);
|
|
648
|
+
|
|
649
|
+
// A pipeline whose decoder feeds a shell, eval or xargs runs what it decoded; xargs handing a reader the literal words of
|
|
650
|
+
// an earlier stage (`printf .env | xargs cat`, `find . -name .env | xargs cat`) reads those words. One pass: each stage
|
|
651
|
+
// is looked at once, and each earlier word is handed over at most once.
|
|
652
|
+
function analyzePipelines(segments, result) {
|
|
653
|
+
let decoded = false;
|
|
654
|
+
let first = true;
|
|
655
|
+
let words = [];
|
|
656
|
+
let handed = 0;
|
|
657
|
+
for (const stage of segments) {
|
|
658
|
+
const info = unwrap(stage);
|
|
659
|
+
if (info.index >= 0 && !first) {
|
|
660
|
+
if (decoded && (SHELLS.has(info.name) || ['eval', 'xargs', 'source', '.'].includes(info.name))) result.decodedRead = true;
|
|
661
|
+
if (info.name === 'xargs') {
|
|
662
|
+
let index = info.index + 1;
|
|
663
|
+
while (index < stage.length && stage[index].text.startsWith('-')) index = skipOption(stage, index, XARGS_OPTION_VALUE);
|
|
664
|
+
const reader = unwrap(stage.slice(index)).name;
|
|
665
|
+
if (READS.has(reader) || SHELLS.has(reader)) {
|
|
666
|
+
result.pipeNames.push(...words.slice(handed));
|
|
667
|
+
handed = words.length;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (DECODERS(stage)) decoded = true;
|
|
672
|
+
for (const word of stage.slice(info.index + 1)) if (word.text && !word.text.startsWith('-')) words.push(word.text);
|
|
673
|
+
first = stage.link !== 'pipe';
|
|
674
|
+
if (first) { decoded = false; words = []; handed = 0; }
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
561
678
|
function commandAccess(command) {
|
|
562
679
|
const result = analyzeCommand(command);
|
|
563
680
|
return { paths: [...result.paths], dynamic: result.dynamic, malformed: result.malformed, searches: result.searches,
|
|
564
|
-
writes: [...result.writes], unknownWrites: [...result.unknownWrites]
|
|
681
|
+
writes: [...result.writes], unknownWrites: [...result.unknownWrites],
|
|
682
|
+
expansions: result.expansions.map(({ text, directory, literals, scopes }) => ({ text, directory, literals, scopes: scopes || [] })),
|
|
683
|
+
pipeNames: [...new Set(result.pipeNames)], decodedRead: result.decodedRead };
|
|
565
684
|
}
|
|
566
685
|
|
|
567
686
|
module.exports = { DYNAMIC_READ_PATH, commandAccess };
|