Files
willtools-demo/WillTools.extension/WillTools.tab/Lab.panel/GodMod.pushbutton/script.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
2026-09-09 08:32:15 -05:00

179 lines
7.4 KiB
Python

# -*- coding: utf-8 -*-
#
# Starts a detached terminal running the `claude` CLI with its working
# directory set to the repository root, so the session comes up on CLAUDE.md
# and tools/rvt.py -- which is what wires it to THIS running Revit.
#
# The terminal is a separate process. Nothing runs on Revit's UI thread, so a
# long agent session cannot freeze or crash Revit, and this button touches the
# model in no way at all: it opens no Transaction, reads no element, and does
# not even import `vibe.helpers`' document handle.
#
# PLAIN `claude`, DEFAULT PERMISSIONS. Standing ruling: this launch must never
# pass --dangerously-skip-permissions, --permission-mode bypassPermissions, or
# any other auto-approve flag. The agent is being pointed at a live Revit
# session and the owner's real model; every tool call it makes is worth a
# human seeing first. If a future edit "just needs" the prompts gone, that is
# a conversation with the owner, not a flag.
#
# Interactive, so this deliberately does NOT use the redirect-both-streams
# runner from vibe.liftexport.run -- that pattern exists to capture a headless
# run without deadlocking on a pipe, and redirecting stdout here would leave
# the CLI with no terminal to draw on. What is reused is the other half of the
# idiom: a generated .cmd does the cd and holds the window open, so nothing
# has to nest quotes inside a wt.exe command line.
#
# WHAT THIS REPLACED: the button used to run tools/dsh-here.cmd, which starts
# `dsh web` (the DeepSeek Harness browser UI) in the same folder. That was the
# whole of its behaviour -- a different agent CLI in the same slot -- so it is
# gone rather than preserved. tools/dsh-here.cmd is left on disk, now
# unreferenced, for the owner to keep or bin.
#
# This button was called GOD_MOD, then `Claude` for the 2026-08-30 ribbon
# rebuild, and is GOD_MOD again after the 2026-08-30 one -- the owner's own
# word, underscore and all. Same button, same folder contents throughout; the
# folder is `GodMod.pushbutton` because a component name becomes a C# class
# name in the generated assembly.
#
# `__title__` is dead weight while `bundle.yaml` exists -- pyRevit reads it
# only `if not self.meta` -- and it is kept in step anyway, so that trimming a
# bundle file cannot resurrect a name nobody uses.
__title__ = 'GOD_MOD'
import io
import os
import tempfile
from System.Diagnostics import Process, ProcessStartInfo
from pyrevit import forms, script
TITLE = 'Claude'
MARKER = 'CLAUDE.md' # the project instructions the session reads
def repo_root():
"""The checkout to start the session in, and whether it looks like one.
This script lives at
<repo>/WillTools.extension/<tab>/<panel>/<button>/script.py
so the repo is four levels above the button. Deriving it means the
checkout can sit anywhere; ~/pyrevit-dev stays as the fallback for the
case where pyRevit does not hand us __file__.
Returns (folder, found_marker). A folder without CLAUDE.md still gets the
session -- it is only missing the project instructions -- but the report
says so rather than letting the agent start somewhere surprising in
silence.
"""
home = os.path.join(os.path.expanduser('~'), 'pyrevit-dev')
try:
root = os.path.dirname(os.path.abspath(__file__))
except NameError:
return home, os.path.isfile(os.path.join(home, MARKER))
for _ in range(4):
root = os.path.dirname(root)
if os.path.isfile(os.path.join(root, MARKER)):
return root, True
if os.path.isfile(os.path.join(home, MARKER)):
return home, True
return root, False
def find_cli():
"""Absolute path to the Claude Code CLI, or None.
`vibe.aiextract.find_claude` already knows the four places a per-user
install lands and honours CLAUDE_CODE_EXE; two copies of that list would
drift. Imported here rather than at module scope so a broken library
still leaves this button able to say what went wrong.
"""
from vibe.aiextract import find_claude
return find_claude()
def write_runner(claude_exe, workdir):
"""A one-shot .cmd that cds, runs claude, and keeps the window up.
It goes in %TEMP%, never in the repo and never beside the model: it holds
an absolute path to somebody's install, which is exactly what the scrub
gate exists to keep out of the tree. Named per Revit process, because
cmd.exe re-reads a batch file as it runs it and two Revits rewriting one
file under each other is a class of bug not worth owning.
"""
folder = os.path.join(tempfile.gettempdir(), 'WillTools')
if not os.path.isdir(folder):
os.makedirs(folder)
runner = os.path.join(folder,
'god-mod-claude-{0}.cmd'.format(os.getpid()))
# newline='' or the CRLFs below get translated again and every line ends
# \r\r\n. cmd.exe survives that, but a batch file nobody can read in a
# text editor is a bad place to be debugging a launch from.
with io.open(runner, 'w', encoding='utf-8', newline='') as fh:
fh.write(u'@echo off\r\n')
fh.write(u'chcp 65001 >nul\r\n')
fh.write(u'title Claude Code - Revit\r\n')
fh.write(u'cd /d "{0}"\r\n'.format(workdir))
fh.write(u'echo Revit dev folder: %CD%\r\n')
fh.write(u'echo.\r\n')
fh.write(u'"{0}"\r\n'.format(claude_exe))
fh.write(u'echo.\r\n')
fh.write(u'echo [claude exited - this window stays open]\r\n')
fh.write(u'pause >nul\r\n')
return runner
def launch(runner, workdir):
"""Start the runner detached. Windows Terminal when it is there."""
psi = ProcessStartInfo()
wt = os.path.join(os.environ.get('LOCALAPPDATA', ''),
'Microsoft', 'WindowsApps', 'wt.exe')
if os.path.isfile(wt):
psi.FileName = wt
psi.Arguments = '-d "{0}" cmd.exe /c "{1}"'.format(workdir, runner)
else:
psi.FileName = 'cmd.exe'
psi.Arguments = '/c "{0}"'.format(runner)
psi.WorkingDirectory = workdir
psi.UseShellExecute = True
return Process.Start(psi)
WORKDIR, HAS_MARKER = repo_root()
try:
CLAUDE = find_cli()
except Exception as ex:
forms.alert('Could not look up the Claude Code CLI:\n\n{0}: {1}'.format(
type(ex).__name__, ex), title=TITLE, exitscript=True)
if not CLAUDE:
forms.alert(
'Claude Code CLI not found.\n\n'
'Install it, or set the CLAUDE_CODE_EXE environment variable to the '
'full path of claude.cmd / claude.exe.\n\n'
'Looked in the usual per-user install folders and on PATH.',
title=TITLE, exitscript=True)
try:
RUNNER = write_runner(CLAUDE, WORKDIR)
launch(RUNNER, WORKDIR)
except Exception as ex:
forms.alert('Could not start Claude Code:\n\n{0}: {1}'.format(
type(ex).__name__, ex), title=TITLE, exitscript=True)
output = script.get_output()
output.print_md('### Claude Code is starting')
output.print_md('Working folder: `{0}`'.format(WORKDIR))
output.print_md('CLI: `{0}`'.format(CLAUDE))
if not HAS_MARKER:
output.print_md(
'**No `{0}` in that folder** - the session will start without the '
'project instructions, and without them it does not know about the '
'bridge to this Revit.'.format(MARKER))
output.print_md(
'It runs with **default permissions**, so it asks before it acts. '
'It can read and drive **this** Revit session through `tools/rvt.py` - '
'ask it things like _"how many walls are on level 2?"_ or _"build me a '
'button that renumbers the selected doors"_.')