@manhquy/flow-skill 0.1.0-rc.1
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/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/README_VN.md +134 -0
- package/SECURITY.md +69 -0
- package/bin/cli.mjs +382 -0
- package/package.json +52 -0
- package/skills/flow/SKILL.md +234 -0
- package/skills/flow/_templates/00-idea.md +16 -0
- package/skills/flow/_templates/00-inspect.md +42 -0
- package/skills/flow/_templates/01-research.md +59 -0
- package/skills/flow/_templates/02-scope.md +71 -0
- package/skills/flow/_templates/03-prd.md +57 -0
- package/skills/flow/_templates/04-adr.md +21 -0
- package/skills/flow/_templates/05-contract.md +59 -0
- package/skills/flow/_templates/card.md +26 -0
- package/skills/flow/_templates/constitution.md +22 -0
- package/skills/flow/eval/fixtures/f01a/flow/01-research.md +77 -0
- package/skills/flow/eval/fixtures/f01b/flow/01-research.md +75 -0
- package/skills/flow/eval/fixtures/f02a/flow/02-scope.md +56 -0
- package/skills/flow/eval/fixtures/f02b/flow/02-scope.md +65 -0
- package/skills/flow/eval/fixtures/fcda/.flow/.gitkeep +0 -0
- package/skills/flow/eval/fixtures/fcda/cards/C-001.md +46 -0
- package/skills/flow/eval/fixtures/fcdb/.flow/.gitkeep +0 -0
- package/skills/flow/eval/fixtures/fcdb/cards/C-001.md +35 -0
- package/skills/flow/eval/manifest.tsv +7 -0
- package/skills/flow/harness/README.md +117 -0
- package/skills/flow/harness/_db.py +228 -0
- package/skills/flow/harness/_domain.py +161 -0
- package/skills/flow/harness/_presence.py +109 -0
- package/skills/flow/harness/flow_harness.py +1103 -0
- package/skills/flow/harness/repo_map.py +93 -0
- package/skills/flow/harness/schema/001-init.sql +129 -0
- package/skills/flow/harness/schema/002-story-verify.sql +9 -0
- package/skills/flow/harness/schema/003-tool-registry.sql +15 -0
- package/skills/flow/harness/schema/004-intervention.sql +15 -0
- package/skills/flow/harness/schema/005-tool-extensions.sql +20 -0
- package/skills/flow/harness/schema/009-accessed-count.sql +11 -0
- package/skills/flow/harness/schema/010-usage-event.sql +42 -0
- package/skills/flow/harness/schema/011-usage-gate-reason.sql +8 -0
- package/skills/flow/harness/schema/012-usage-ephemeral.sql +10 -0
- package/skills/flow/law/CLAUDE.md +140 -0
- package/skills/flow/law/DESIGN.md +141 -0
- package/skills/flow/law/RETRO.md +13 -0
- package/skills/flow/playbooks/README.md +28 -0
- package/skills/flow/playbooks/cloudflare-ai-gateway-qwen.md +346 -0
- package/skills/flow/playbooks/docker-deploy-stale-cache.md +32 -0
- package/skills/flow/playbooks/rag-lite-postgres-cloudflare.md +192 -0
- package/skills/flow/playbooks/windows-gitbash-jsonl-telemetry.md +57 -0
- package/skills/flow/references/adversarial-review.md +197 -0
- package/skills/flow/references/agent-detection.md +88 -0
- package/skills/flow/references/agent-stage-mapping.md +114 -0
- package/skills/flow/references/antigravity-integration.md +92 -0
- package/skills/flow/references/auto-run.md +81 -0
- package/skills/flow/references/claudekit-skills.md +128 -0
- package/skills/flow/references/codex-integration.md +104 -0
- package/skills/flow/references/command-dispatch.md +54 -0
- package/skills/flow/references/debt-and-halts.md +41 -0
- package/skills/flow/references/design-review-checklist.md +42 -0
- package/skills/flow/references/gate-eval.md +186 -0
- package/skills/flow/references/gate-rules.md +171 -0
- package/skills/flow/references/ground-truth-gates.md +49 -0
- package/skills/flow/references/loop-harness-2026-principles.md +52 -0
- package/skills/flow/references/mode-work.md +37 -0
- package/skills/flow/references/project-types.md +42 -0
- package/skills/flow/references/stage-state-machine.md +47 -0
- package/skills/flow/references/ui-patterns-tcr.md +49 -0
- package/skills/flow/runner/flow.cmd +18 -0
- package/skills/flow/runner/flow.sh +3297 -0
- package/skills-manifest.json +70 -0
- package/src/constants.mjs +65 -0
- package/src/detect.mjs +36 -0
- package/src/help.mjs +42 -0
- package/src/installer.mjs +231 -0
- package/src/prompt.mjs +60 -0
- package/src/tty.mjs +11 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "D:\\project\\flow\\flow-skill\\skills\\flow",
|
|
3
|
+
"destRelative": "skills/flow",
|
|
4
|
+
"fileCount": 62,
|
|
5
|
+
"syncedAt": "2026-07-12T04:37:28.869Z",
|
|
6
|
+
"fileList": [
|
|
7
|
+
"SKILL.md",
|
|
8
|
+
"_templates/00-idea.md",
|
|
9
|
+
"_templates/00-inspect.md",
|
|
10
|
+
"_templates/01-research.md",
|
|
11
|
+
"_templates/02-scope.md",
|
|
12
|
+
"_templates/03-prd.md",
|
|
13
|
+
"_templates/04-adr.md",
|
|
14
|
+
"_templates/05-contract.md",
|
|
15
|
+
"_templates/card.md",
|
|
16
|
+
"_templates/constitution.md",
|
|
17
|
+
"eval/fixtures/f01a/flow/01-research.md",
|
|
18
|
+
"eval/fixtures/f01b/flow/01-research.md",
|
|
19
|
+
"eval/fixtures/f02a/flow/02-scope.md",
|
|
20
|
+
"eval/fixtures/f02b/flow/02-scope.md",
|
|
21
|
+
"eval/fixtures/fcda/.flow/.gitkeep",
|
|
22
|
+
"eval/fixtures/fcda/cards/C-001.md",
|
|
23
|
+
"eval/fixtures/fcdb/.flow/.gitkeep",
|
|
24
|
+
"eval/fixtures/fcdb/cards/C-001.md",
|
|
25
|
+
"eval/manifest.tsv",
|
|
26
|
+
"harness/README.md",
|
|
27
|
+
"harness/_db.py",
|
|
28
|
+
"harness/_domain.py",
|
|
29
|
+
"harness/_presence.py",
|
|
30
|
+
"harness/flow_harness.py",
|
|
31
|
+
"harness/repo_map.py",
|
|
32
|
+
"harness/schema/001-init.sql",
|
|
33
|
+
"harness/schema/002-story-verify.sql",
|
|
34
|
+
"harness/schema/003-tool-registry.sql",
|
|
35
|
+
"harness/schema/004-intervention.sql",
|
|
36
|
+
"harness/schema/005-tool-extensions.sql",
|
|
37
|
+
"harness/schema/009-accessed-count.sql",
|
|
38
|
+
"harness/schema/010-usage-event.sql",
|
|
39
|
+
"harness/schema/011-usage-gate-reason.sql",
|
|
40
|
+
"harness/schema/012-usage-ephemeral.sql",
|
|
41
|
+
"law/CLAUDE.md",
|
|
42
|
+
"law/DESIGN.md",
|
|
43
|
+
"law/RETRO.md",
|
|
44
|
+
"playbooks/README.md",
|
|
45
|
+
"playbooks/cloudflare-ai-gateway-qwen.md",
|
|
46
|
+
"playbooks/docker-deploy-stale-cache.md",
|
|
47
|
+
"playbooks/rag-lite-postgres-cloudflare.md",
|
|
48
|
+
"playbooks/windows-gitbash-jsonl-telemetry.md",
|
|
49
|
+
"references/adversarial-review.md",
|
|
50
|
+
"references/agent-detection.md",
|
|
51
|
+
"references/agent-stage-mapping.md",
|
|
52
|
+
"references/antigravity-integration.md",
|
|
53
|
+
"references/auto-run.md",
|
|
54
|
+
"references/claudekit-skills.md",
|
|
55
|
+
"references/codex-integration.md",
|
|
56
|
+
"references/command-dispatch.md",
|
|
57
|
+
"references/debt-and-halts.md",
|
|
58
|
+
"references/design-review-checklist.md",
|
|
59
|
+
"references/gate-eval.md",
|
|
60
|
+
"references/gate-rules.md",
|
|
61
|
+
"references/ground-truth-gates.md",
|
|
62
|
+
"references/loop-harness-2026-principles.md",
|
|
63
|
+
"references/mode-work.md",
|
|
64
|
+
"references/project-types.md",
|
|
65
|
+
"references/stage-state-machine.md",
|
|
66
|
+
"references/ui-patterns-tcr.md",
|
|
67
|
+
"runner/flow.cmd",
|
|
68
|
+
"runner/flow.sh"
|
|
69
|
+
]
|
|
70
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// 4 install targets. Antigravity has 2 destinations (CLI + IDE) driven by one target.
|
|
2
|
+
// Marker paths are relative to os.homedir() — no leading slash/backslash.
|
|
3
|
+
export const TARGETS = [
|
|
4
|
+
{
|
|
5
|
+
name: 'claude',
|
|
6
|
+
label: 'Claude Code',
|
|
7
|
+
// any presence of ~/.claude counts — Claude is `alwaysInclude` regardless
|
|
8
|
+
markers: ['.claude'],
|
|
9
|
+
destTemplates: ['~/.claude/skills/flow'],
|
|
10
|
+
alwaysInclude: true,
|
|
11
|
+
projectScopeAllowed: true,
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'codex',
|
|
15
|
+
label: 'Codex CLI',
|
|
16
|
+
markers: ['.codex/skills'],
|
|
17
|
+
destTemplates: ['~/.codex/skills/flow'],
|
|
18
|
+
alwaysInclude: false,
|
|
19
|
+
projectScopeAllowed: false,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'agents',
|
|
23
|
+
label: 'Agents home (~/.agents)',
|
|
24
|
+
markers: ['.agents/skills'],
|
|
25
|
+
destTemplates: ['~/.agents/skills/flow'],
|
|
26
|
+
alwaysInclude: false,
|
|
27
|
+
projectScopeAllowed: false,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'antigravity',
|
|
31
|
+
label: 'Antigravity (CLI + IDE)',
|
|
32
|
+
// Never stat bare ~/.gemini — that dir is shared by non-Antigravity Google Gemini CLI.
|
|
33
|
+
markers: ['.gemini/antigravity-cli', '.gemini/config/skills'],
|
|
34
|
+
destTemplates: [
|
|
35
|
+
'~/.gemini/antigravity-cli/skills/flow',
|
|
36
|
+
'~/.gemini/config/skills/flow',
|
|
37
|
+
],
|
|
38
|
+
alwaysInclude: false,
|
|
39
|
+
projectScopeAllowed: false,
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Subdirs the installer cleans out before merge-copying — parity with install.sh:25.
|
|
44
|
+
export const CLEANUP_SUBDIRS = [
|
|
45
|
+
'runner',
|
|
46
|
+
'_templates',
|
|
47
|
+
'law',
|
|
48
|
+
'references',
|
|
49
|
+
'harness',
|
|
50
|
+
'playbooks',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
export const TARGET_NAMES = TARGETS.map((t) => t.name);
|
|
54
|
+
export const CLI_NAME = 'flow-skill';
|
|
55
|
+
|
|
56
|
+
// F3 fix: read version from package.json at runtime so `npm version` bumps propagate to the
|
|
57
|
+
// JSON `plan` event without a hand-edit here.
|
|
58
|
+
import { readFileSync } from 'node:fs';
|
|
59
|
+
import { fileURLToPath } from 'node:url';
|
|
60
|
+
import { dirname, resolve } from 'node:path';
|
|
61
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
const pkgJson = JSON.parse(
|
|
63
|
+
readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8')
|
|
64
|
+
);
|
|
65
|
+
export const PKG_VERSION = pkgJson.version;
|
package/src/detect.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { TARGETS } from './constants.mjs';
|
|
6
|
+
|
|
7
|
+
// Resolve a `~/foo/bar` template against a home dir using OS-native separators.
|
|
8
|
+
// String `.replace('~', home)` would leave a mixed-separator path on Windows
|
|
9
|
+
// (`C:\Users\x/.claude/skills/flow`) which then leaks into `--json` output and
|
|
10
|
+
// breaks downstream CI consumers that key on path strings.
|
|
11
|
+
function resolveDest(template, home) {
|
|
12
|
+
if (!template.startsWith('~/')) return template;
|
|
13
|
+
const rest = template.slice(2).split('/');
|
|
14
|
+
return join(home, ...rest);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Return one entry per target with marker-based detection + resolved absolute destinations.
|
|
18
|
+
export function detectAll({ home = homedir() } = {}) {
|
|
19
|
+
return TARGETS.map((t) => {
|
|
20
|
+
const detected = t.markers.some((m) => {
|
|
21
|
+
try {
|
|
22
|
+
statSync(join(home, m));
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
const dests = t.destTemplates.map((d) => resolveDest(d, home));
|
|
29
|
+
return { ...t, detected, dests };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// alwaysInclude || detected — mirrors install.sh:40 contract for Claude and preserves user-facing detection for others.
|
|
34
|
+
export function defaultSelection(entries) {
|
|
35
|
+
return entries.filter((e) => e.alwaysInclude || e.detected).map((e) => e.name);
|
|
36
|
+
}
|
package/src/help.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CLI_NAME, PKG_VERSION, TARGETS } from './constants.mjs';
|
|
2
|
+
|
|
3
|
+
export function renderHelp() {
|
|
4
|
+
const targetRows = TARGETS.map((t) => ` ${t.name.padEnd(12)} ${t.label}`).join('\n');
|
|
5
|
+
return `${CLI_NAME} v${PKG_VERSION}
|
|
6
|
+
|
|
7
|
+
Install the flow skill into your coding agent(s).
|
|
8
|
+
|
|
9
|
+
USAGE
|
|
10
|
+
npx @manhquy/flow-skill [options]
|
|
11
|
+
|
|
12
|
+
OPTIONS
|
|
13
|
+
-y, --yes Skip prompts; install to default selection (detected + Claude)
|
|
14
|
+
-t, --target <name> Explicit target (repeatable, comma-separated also OK)
|
|
15
|
+
--all Install to all 4 targets regardless of detection
|
|
16
|
+
--project Project scope (Claude only) — writes to <dir>/.claude/skills/flow
|
|
17
|
+
--dir <path> Project directory (implies --project). Default: cwd
|
|
18
|
+
--json Emit JSONL events (plan, install:start, install:done, summary)
|
|
19
|
+
--dry-run Print the plan; do not touch disk
|
|
20
|
+
-h, --help Show this help
|
|
21
|
+
|
|
22
|
+
TARGETS
|
|
23
|
+
${targetRows}
|
|
24
|
+
|
|
25
|
+
Notes:
|
|
26
|
+
- Antigravity target writes 2 destinations: ~/.gemini/antigravity-cli/skills/flow AND ~/.gemini/config/skills/flow.
|
|
27
|
+
- --project scope supports only "claude" (agent-contract limitation).
|
|
28
|
+
- Non-detected targets can still be forced via --target or --all.
|
|
29
|
+
|
|
30
|
+
EXAMPLES
|
|
31
|
+
npx @manhquy/flow-skill # interactive multi-select
|
|
32
|
+
npx @manhquy/flow-skill --yes # non-interactive; default selection
|
|
33
|
+
npx @manhquy/flow-skill --yes --all # force all 4 targets
|
|
34
|
+
npx @manhquy/flow-skill --yes -t claude -t codex # explicit targets
|
|
35
|
+
npx @manhquy/flow-skill --project --dir . # project-scoped install (Claude only)
|
|
36
|
+
npx @manhquy/flow-skill --yes --all --dry-run --json # CI JSONL preview
|
|
37
|
+
|
|
38
|
+
MORE
|
|
39
|
+
Docs: https://github.com/manhquydev/flow-skill#readme
|
|
40
|
+
Security: https://github.com/manhquydev/flow-skill/blob/main/SECURITY.md
|
|
41
|
+
`;
|
|
42
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
cpSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { CLEANUP_SUBDIRS } from './constants.mjs';
|
|
15
|
+
|
|
16
|
+
// L24 — Retry the codes Windows emits when an agent (Claude Code, Codex, Antigravity) holds an
|
|
17
|
+
// open handle inside the destination. EACCES is intentionally excluded: on POSIX it is almost
|
|
18
|
+
// always a hard permission denial (stale sudo install, wrong owner) that will not clear in
|
|
19
|
+
// under a second. Retrying it just delays the honest error message.
|
|
20
|
+
export function withRetry(fn, opts = {}) {
|
|
21
|
+
const attempts = opts.attempts ?? 3;
|
|
22
|
+
const initialMs = opts.initialMs ?? 100;
|
|
23
|
+
const retryableCodes = opts.codes ?? ['EBUSY', 'EPERM', 'ENOTEMPTY'];
|
|
24
|
+
let lastErr;
|
|
25
|
+
for (let i = 0; i < attempts; i++) {
|
|
26
|
+
try {
|
|
27
|
+
return fn();
|
|
28
|
+
} catch (err) {
|
|
29
|
+
lastErr = err;
|
|
30
|
+
if (!retryableCodes.includes(err?.code) || i === attempts - 1) throw err;
|
|
31
|
+
// Blocking sleep — installer runs sequentially, this is fine.
|
|
32
|
+
const wait = initialMs * Math.pow(3, i);
|
|
33
|
+
const sab = new SharedArrayBuffer(4);
|
|
34
|
+
Atomics.wait(new Int32Array(sab), 0, 0, wait);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw lastErr;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function rmRecursiveWithRetry(target) {
|
|
41
|
+
return withRetry(() => rmSync(target, { recursive: true, force: true }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// L23 — Reject any symlink recursively. Defense against upstream skill-content symlink smuggling
|
|
45
|
+
// (e.g. symlink → /etc/passwd smuggled into skills/flow via compromised upstream).
|
|
46
|
+
export function assertNoSymlinks(root) {
|
|
47
|
+
for (const entry of readdirSync(root)) {
|
|
48
|
+
const p = join(root, entry);
|
|
49
|
+
const st = lstatSync(p);
|
|
50
|
+
if (st.isSymbolicLink()) {
|
|
51
|
+
const err = new Error(`symlink rejected: ${p}`);
|
|
52
|
+
err.code = 'ESYMLINK';
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
if (st.isDirectory()) assertNoSymlinks(p);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// L29 — Advisory lock: best-effort concurrent-run prevention.
|
|
60
|
+
// Uses `writeFileSync` with `flag: 'wx'` (exclusive-create — fails with EEXIST if the file
|
|
61
|
+
// already exists) so the check + acquire are atomic. The previous TOCTOU version could have
|
|
62
|
+
// two `npx` processes both observe "no lock" and both acquire.
|
|
63
|
+
// Stale-lock recovery: on EEXIST, read the recorded PID; if that PID is dead (ESRCH), remove
|
|
64
|
+
// the file and retry the exclusive-create once. `process.kill(pid, 0)` on a live PID always
|
|
65
|
+
// succeeds, including our own PID — so a re-entrant call sees "held" and refuses, which is
|
|
66
|
+
// the correct behavior (install() is not re-entrant-safe).
|
|
67
|
+
export function acquireLock(lockPath) {
|
|
68
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
|
71
|
+
return { held: false };
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err?.code !== 'EEXIST') throw err;
|
|
74
|
+
}
|
|
75
|
+
// Lock exists — decide stale vs. held.
|
|
76
|
+
let pid = null;
|
|
77
|
+
try {
|
|
78
|
+
pid = Number(readFileSync(lockPath, 'utf8').trim());
|
|
79
|
+
} catch {
|
|
80
|
+
// corrupted lock content — treat as stale
|
|
81
|
+
}
|
|
82
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
83
|
+
try {
|
|
84
|
+
process.kill(pid, 0);
|
|
85
|
+
return { held: true, pid };
|
|
86
|
+
} catch (killErr) {
|
|
87
|
+
if (killErr?.code !== 'ESRCH') return { held: true, pid };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Stale — remove and retry the exclusive-create exactly once.
|
|
91
|
+
try {
|
|
92
|
+
rmSync(lockPath, { force: true });
|
|
93
|
+
} catch {
|
|
94
|
+
/* ignore */
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
|
98
|
+
return { held: false };
|
|
99
|
+
} catch (retryErr) {
|
|
100
|
+
// Something else raced in between our rm and our retry — treat as held.
|
|
101
|
+
if (retryErr?.code === 'EEXIST') return { held: true, pid: null };
|
|
102
|
+
throw retryErr;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function releaseLock(lockPath) {
|
|
107
|
+
try {
|
|
108
|
+
rmSync(lockPath, { force: true });
|
|
109
|
+
} catch {
|
|
110
|
+
/* ignore */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Core install — parity with install.sh:24-27:
|
|
115
|
+
// rm -rf <dest>/{runner,_templates,law,references,harness,playbooks}
|
|
116
|
+
// cp -r <sourceDir>/. <dest>/
|
|
117
|
+
// chmod +x <dest>/runner/flow.sh
|
|
118
|
+
// This intentionally preserves any user files in <dest> outside the 6 cleanup subdirs.
|
|
119
|
+
export function install({
|
|
120
|
+
sourceDir,
|
|
121
|
+
destDir,
|
|
122
|
+
cleanupSubdirs = CLEANUP_SUBDIRS,
|
|
123
|
+
dryRun = false,
|
|
124
|
+
}) {
|
|
125
|
+
if (dryRun) return { success: true, dest: destDir, dryRun: true, warnings: [] };
|
|
126
|
+
|
|
127
|
+
const lockPath = join(dirname(destDir), '.flow-skill.installing.lock');
|
|
128
|
+
const warnings = [];
|
|
129
|
+
let lockHeld = false;
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const lock = acquireLock(lockPath);
|
|
133
|
+
if (lock.held) {
|
|
134
|
+
return {
|
|
135
|
+
success: false,
|
|
136
|
+
dest: destDir,
|
|
137
|
+
error: `another install in progress (pid ${lock.pid}); wait or remove ${lockPath}`,
|
|
138
|
+
warnings,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
lockHeld = true;
|
|
142
|
+
|
|
143
|
+
if (!existsSync(join(sourceDir, 'SKILL.md'))) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`SKILL.md missing in sourceDir=${sourceDir} (did you run 'npm run sync'?)`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
// Pre-scan source before touching dest.
|
|
149
|
+
assertNoSymlinks(sourceDir);
|
|
150
|
+
|
|
151
|
+
mkdirSync(destDir, { recursive: true });
|
|
152
|
+
|
|
153
|
+
// Cleanup the 6 subdirs — matches install.sh:25.
|
|
154
|
+
for (const sub of cleanupSubdirs) {
|
|
155
|
+
const p = join(destDir, sub);
|
|
156
|
+
if (existsSync(p)) rmRecursiveWithRetry(p);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Merge copy — matches install.sh:26. External files outside CLEANUP_SUBDIRS are preserved.
|
|
160
|
+
withRetry(() =>
|
|
161
|
+
cpSync(sourceDir, destDir, {
|
|
162
|
+
recursive: true,
|
|
163
|
+
force: true,
|
|
164
|
+
dereference: false,
|
|
165
|
+
errorOnBrokenSymbolicLinks: true,
|
|
166
|
+
preserveTimestamps: true,
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// (Post-copy symlink rescan removed) — the source scan above already vets every file we
|
|
171
|
+
// wrote; scanning the destination additionally rejects legitimate NTFS junctions users
|
|
172
|
+
// may have placed under `~/.claude/skills/flow/` for their own reasons. install.sh does
|
|
173
|
+
// not have this restriction (`cp -r` merges over junctions fine) and the parity contract
|
|
174
|
+
// says we don't either.
|
|
175
|
+
|
|
176
|
+
// chmod +x runner/flow.sh — matches install.sh:27. No-op on Windows (NTFS ignores the bit).
|
|
177
|
+
const runnerScript = join(destDir, 'runner', 'flow.sh');
|
|
178
|
+
if (existsSync(runnerScript)) {
|
|
179
|
+
try {
|
|
180
|
+
chmodSync(runnerScript, 0o755);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
warnings.push(`chmod +x runner/flow.sh failed: ${err.message}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { success: true, dest: destDir, warnings };
|
|
187
|
+
} catch (err) {
|
|
188
|
+
return {
|
|
189
|
+
success: false,
|
|
190
|
+
dest: destDir,
|
|
191
|
+
error: String(err?.message ?? err),
|
|
192
|
+
warnings: [
|
|
193
|
+
...warnings,
|
|
194
|
+
'state may be inconsistent (partial write); re-run to fix',
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
} finally {
|
|
198
|
+
if (lockHeld) releaseLock(lockPath);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// L28 (revised per phase-01 review F1) — Antigravity has 2 destinations under one target.
|
|
203
|
+
// install.sh:52-53 makes 2 separate install_to calls with no rollback if the 2nd fails; we
|
|
204
|
+
// mirror that. Rolling back dest 1 destroys the freshly-installed content there and also
|
|
205
|
+
// risks harming anything the user had before install started, so we intentionally do NOT roll
|
|
206
|
+
// back. On dest-2 failure we report partial success and let the user re-run.
|
|
207
|
+
export function installAntigravity({ sourceDir, dests, dryRun = false }) {
|
|
208
|
+
if (!Array.isArray(dests) || dests.length === 0) {
|
|
209
|
+
return { success: false, error: 'no destinations provided' };
|
|
210
|
+
}
|
|
211
|
+
if (dryRun) return { success: true, dests, dryRun: true, warnings: [] };
|
|
212
|
+
|
|
213
|
+
const attempts = [];
|
|
214
|
+
for (let i = 0; i < dests.length; i++) {
|
|
215
|
+
const r = install({ sourceDir, destDir: dests[i] });
|
|
216
|
+
attempts.push({ dest: dests[i], success: r.success, error: r.error ?? null });
|
|
217
|
+
if (!r.success) {
|
|
218
|
+
const warnings = attempts.flatMap((a, idx) =>
|
|
219
|
+
idx < i ? [`dest ${idx + 1} (${dests[idx]}) already installed; not rolled back`] : []
|
|
220
|
+
);
|
|
221
|
+
return {
|
|
222
|
+
success: false,
|
|
223
|
+
dests,
|
|
224
|
+
attempts,
|
|
225
|
+
error: `dest ${i + 1} failed: ${r.error}`,
|
|
226
|
+
warnings,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { success: true, dests, attempts, warnings: [] };
|
|
231
|
+
}
|
package/src/prompt.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @clack/prompts wrapper — kept small so Phase-1 non-interactive path never has to load it.
|
|
2
|
+
import { cancel, confirm, intro, isCancel, multiselect, outro, select } from '@clack/prompts';
|
|
3
|
+
|
|
4
|
+
import { TARGETS } from './constants.mjs';
|
|
5
|
+
|
|
6
|
+
// Present the 4 targets as a checkbox list. Detected targets (and Claude via alwaysInclude) are
|
|
7
|
+
// pre-checked. Not-detected entries still appear so the user can force-add.
|
|
8
|
+
export async function promptTargets(entries) {
|
|
9
|
+
const options = entries.map((e) => ({
|
|
10
|
+
value: e.name,
|
|
11
|
+
label: `${e.label}${e.detected || e.alwaysInclude ? '' : ' (not detected)'}`,
|
|
12
|
+
hint: e.dests.join(', '),
|
|
13
|
+
}));
|
|
14
|
+
const initialValues = entries
|
|
15
|
+
.filter((e) => e.alwaysInclude || e.detected)
|
|
16
|
+
.map((e) => e.name);
|
|
17
|
+
|
|
18
|
+
const selected = await multiselect({
|
|
19
|
+
message: 'Install to which harnesses?',
|
|
20
|
+
options,
|
|
21
|
+
initialValues,
|
|
22
|
+
required: true,
|
|
23
|
+
});
|
|
24
|
+
if (isCancel(selected)) return { cancelled: true };
|
|
25
|
+
return { cancelled: false, targets: selected };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Global vs project scope. Project scope is Claude-only (agent contract).
|
|
29
|
+
export async function promptScope(defaultDir = process.cwd()) {
|
|
30
|
+
const scope = await select({
|
|
31
|
+
message: 'Scope?',
|
|
32
|
+
options: [
|
|
33
|
+
{ value: 'global', label: 'Global (all agents that were selected)' },
|
|
34
|
+
{ value: 'project', label: `Project (${defaultDir}) — Claude only` },
|
|
35
|
+
],
|
|
36
|
+
initialValue: 'global',
|
|
37
|
+
});
|
|
38
|
+
if (isCancel(scope)) return { cancelled: true };
|
|
39
|
+
return { cancelled: false, scope };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Apply project-scope filtering with a visible warning about dropped targets.
|
|
43
|
+
export function filterProjectScope(selected) {
|
|
44
|
+
const kept = selected.filter(
|
|
45
|
+
(n) => TARGETS.find((t) => t.name === n)?.projectScopeAllowed
|
|
46
|
+
);
|
|
47
|
+
const dropped = selected.filter((n) => !kept.includes(n));
|
|
48
|
+
return { kept, dropped };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function promptConfirm(summary) {
|
|
52
|
+
const ok = await confirm({
|
|
53
|
+
message: `Install ${summary.count} target(s)?`,
|
|
54
|
+
initialValue: true,
|
|
55
|
+
});
|
|
56
|
+
if (isCancel(ok)) return { cancelled: true };
|
|
57
|
+
return { cancelled: false, ok };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { cancel, intro, isCancel, outro };
|
package/src/tty.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Interactive-mode gate. Both stdio streams must be TTY and no CI env override in effect.
|
|
2
|
+
// `NON_INTERACTIVE=1` is a documented escape hatch for edge terminals (some VS Code integrated
|
|
3
|
+
// shells report isTTY=true but do not render prompt UI correctly).
|
|
4
|
+
export function isInteractive({
|
|
5
|
+
stdin = process.stdin,
|
|
6
|
+
stdout = process.stdout,
|
|
7
|
+
env = process.env,
|
|
8
|
+
} = {}) {
|
|
9
|
+
if (env.CI === 'true' || env.NON_INTERACTIVE === '1') return false;
|
|
10
|
+
return Boolean(stdin.isTTY && stdout.isTTY);
|
|
11
|
+
}
|